Voltar para Artigos
Front-end21 min de leitura

Microinterações e Animações Fluidas com Framer Motion

Do básico ao avançado: variants, stagger, AnimatePresence, useScroll, layout animations e como garantir 60fps sem travar a thread principal.

12 de agosto de 2026

Em produtos digitais de alta qualidade, a transição entre telas, o comportamento de um botão ou o surgimento de um card não são abruptos. Esses micro-momentos constroem percepção de qualidade no subconsciente do usuário. O Framer Motion é uma biblioteca declarativa para React que abstrai o complexo modelo de animação do browser e entrega uma API elegante, com animações baseadas em física e integração nativa com o ciclo de vida dos componentes.

Mas a maioria dos tutoriais fica apenas no initial/animate/transition. Neste artigo, vamos cobrir o que realmente diferencia uma interface mediana de uma premium: Variants, stagger de filhos, AnimatePresence para unmount, scroll-driven animations, layout animations e como medir e garantir performance real.

bash
npm install framer-motion

Fundamentos: O modelo mental do Framer Motion

Todo motion.element opera com três estados principais:

  • `initial` — estado antes da animação de entrada (posição, opacidade, escala inicial)
  • `animate` — estado alvo para onde o elemento anima ao montar
  • `exit` — estado para onde anima antes de ser removido do DOM (só funciona dentro de AnimatePresence)

Por padrão, o Framer Motion usa spring physics (molas) ao invés de easing cubic-bezier. Isso resulta em animações que parecem físicas e naturais. Você pode ajustar com transition:

SpringDemo.tsx
import { motion } from 'framer-motion';

export function SpringDemo() {
  return (
    <motion.div
      initial={{ opacity: 0, y: 40 }}
      animate={{ opacity: 1, y: 0 }}
      // Spring: stiffness (rigidez), damping (amortecimento)
      // Quanto maior stiffness, mais rápido. Menor damping = mais oscilação.
      transition={{
        type: 'spring',
        stiffness: 260,
        damping: 20,
      }}
    >
      Conteúdo com física de mola
    </motion.div>
  );
}

// Para animações de opacidade simples, use 'tween' com easing:
// transition={{ type: 'tween', duration: 0.3, ease: 'easeOut' }}
// Spring é ruim para opacidade — use tween nesses casos.

Variants: Reutilização e Consistência

O maior erro de quem começa com Framer Motion é espalhar objetos de animação inline em cada componente. Variants são a forma certa: você define os estados como um objeto reutilizável e referencia por nome. Isso centraliza a animação e habilita funcionalidades poderosas como propagação automática para filhos.

VariantsExample.tsx
import { motion } from 'framer-motion';

// Definição das variantes fora do componente — sem re-criação a cada render
const containerVariants = {
  hidden: { opacity: 0 },
  visible: {
    opacity: 1,
    transition: {
      // staggerChildren: delay entre a animação de cada filho
      staggerChildren: 0.1,
      // delayChildren: delay antes do primeiro filho começar
      delayChildren: 0.2,
    },
  },
};

const itemVariants = {
  hidden: { opacity: 0, y: 20 },
  visible: {
    opacity: 1,
    y: 0,
    transition: { type: 'spring', stiffness: 300, damping: 24 },
  },
};

interface CardListProps {
  items: Array<string>;
}

export function CardList({ items }: CardListProps) {
  return (
    // Pai anima de 'hidden' para 'visible'
    // Filhos com motion + variants herdam automaticamente esses nomes
    <motion.ul
      variants={containerVariants}
      initial="hidden"
      animate="visible"
    >
      {items.map((item, index) => (
        <motion.li
          key={index}
          variants={itemVariants}
          // Não precisa de initial/animate — herda do pai!
        >
          {item}
        </motion.li>
      ))}
    </motion.ul>
  );
}

// Resultado: os cards entram com 100ms de delay entre cada um,
// criando aquele efeito cascata de listas que parece premium.

Definir as variantes fora do componente é crucial para performance. Se você as define dentro, um novo objeto é criado a cada render, fazendo o Framer Motion reprocessar as animações desnecessariamente.

AnimatePresence: Animando o Unmount

O React remove componentes do DOM imediatamente quando a condição é false. O AnimatePresence intercepta esse unmount e deixa o componente executar sua animação exit antes de ser removido de verdade. É essencial para modais, toasts, drawers e qualquer conteúdo condicional.

NotificationStack.tsx
import { motion, AnimatePresence } from 'framer-motion';
import { useState } from 'react';

interface Notification {
  id: string;
  message: string;
  type: 'success' | 'error' | 'info';
}

const notificationVariants = {
  initial: { opacity: 0, x: 60, scale: 0.95 },
  animate: { opacity: 1, x: 0, scale: 1 },
  exit: { opacity: 0, x: 60, scale: 0.95, transition: { duration: 0.2 } },
};

export function NotificationStack() {
  const [notifications, setNotifications] = useState<Array<Notification>>([]);

  function dismiss(id: string) {
    setNotifications((prev) => prev.filter((n) => n.id !== id));
  }

  return (
    <div className="notification-stack">
      {/* mode="popLayout" é mais eficiente para listas que mudam de tamanho */}
      <AnimatePresence mode="popLayout">
        {notifications.map((notification) => (
          <motion.div
            key={notification.id}
            layout // Anima o reposicionamento dos outros itens quando um some
            variants={notificationVariants}
            initial="initial"
            animate="animate"
            exit="exit"
            className={`notification notification--${notification.type}`}
          >
            <p>{notification.message}</p>
            <button
              type="button"
              onClick={() => dismiss(notification.id)}
              aria-label="Dispensar notificação"
            >
              ✕
            </button>
          </motion.div>
        ))}
      </AnimatePresence>
    </div>
  );
}

// mode="wait" — espera o exit completar antes de animar o próximo (bom para page transitions)
// mode="sync" — entra e sai ao mesmo tempo (padrão)
// mode="popLayout" — ideal para listas (mais eficiente, evita layout shifts)

Layout Animations: Transições de Layout Automáticas

Layout Animation é uma das features mais impressionantes do Framer Motion. Basta adicionar a prop layout a um motion.element e ele automaticamente anima qualquer mudança de posição ou tamanho que ocorra por causa de mudanças de estado, sem você precisar calcular coordenadas.

SortableList.tsx
import { motion, AnimatePresence } from 'framer-motion';
import { useState } from 'react';

interface Task {
  id: string;
  text: string;
  done: boolean;
}

export function TaskList({ tasks }: { tasks: Array<Task> }) {
  const [items, setItems] = useState(tasks);

  function toggleDone(id: string) {
    setItems((prev) =>
      prev.map((task) =>
        task.id === id ? { ...task, done: !task.done } : task
      ).sort((a, b) => Number(a.done) - Number(b.done)) // Concluídos vão para o final
    );
  }

  return (
    <ul>
      <AnimatePresence>
        {items.map((task) => (
          // layout="position" — anima apenas o movimento (não o tamanho)
          // Quando a lista reordena, cada item desliza suavemente para sua nova posição
          <motion.li
            key={task.id}
            layout="position"
            initial={{ opacity: 0 }}
            animate={{ opacity: 1 }}
            exit={{ opacity: 0 }}
          >
            <input
              type="checkbox"
              checked={task.done}
              onChange={() => toggleDone(task.id)}
            />
            <span style={{ textDecoration: task.done ? 'line-through' : 'none' }}>
              {task.text}
            </span>
          </motion.li>
        ))}
      </AnimatePresence>
    </ul>
  );
}

// Sem Framer Motion, você precisaria de:
// - Medir a posição de cada item antes e depois (getBoundingClientRect)
// - Calcular o delta de posição
// - Aplicar um transform inicial e animar para 0
// (padrão FLIP — First, Last, Invert, Play)
// O Framer Motion faz tudo isso automaticamente com a prop 'layout'.

Scroll-Driven Animations com useScroll e useTransform

Animações controladas pelo scroll são extremamente populares em landing pages e portfólios. O Framer Motion oferece useScroll para rastrear a posição de scroll e useTransform para mapear esse valor para qualquer propriedade CSS — criando efeitos parallax, fade-on-scroll e progress bars sem uma linha de imperative code.

ScrollEffects.tsx
import { useRef } from 'react';
import { motion, useScroll, useTransform, useSpring } from 'framer-motion';

// --- Exemplo 1: Progress Bar de Leitura ---
export function ReadingProgress() {
  // scrollYProgress: 0 (topo) → 1 (fim da página)
  const { scrollYProgress } = useScroll();

  // useSpring suaviza o valor do scroll (sem isso, a barra pode "pular")
  const scaleX = useSpring(scrollYProgress, {
    stiffness: 100,
    damping: 30,
    restDelta: 0.001,
  });

  return (
    <motion.div
      style={{
        scaleX, // transform-origin: left por padrão
        position: 'fixed',
        top: 0,
        left: 0,
        right: 0,
        height: 3,
        background: 'linear-gradient(90deg, #6366f1, #8b5cf6)',
        transformOrigin: '0%',
      }}
    />
  );
}

// --- Exemplo 2: Parallax em seção ---
export function ParallaxSection() {
  const ref = useRef<HTMLDivElement>(null);

  // Rastreia o scroll relativo ao container, não à página inteira
  const { scrollYProgress } = useScroll({
    target: ref,
    offset: ['start end', 'end start'], // Começa quando entra na viewport, termina quando sai
  });

  // Mapeia 0→1 do scroll para -50px→50px no eixo Y (parallax)
  const y = useTransform(scrollYProgress, [0, 1], ['-15%', '15%']);
  // Fade in ao entrar, fade out ao sair
  const opacity = useTransform(scrollYProgress, [0, 0.2, 0.8, 1], [0, 1, 1, 0]);

  return (
    <div ref={ref} className="parallax-section">
      <motion.div style={{ y, opacity }} className="parallax-content">
        <h2>Seção com Parallax</h2>
        <p>Este conteúdo se move a uma velocidade diferente do scroll.</p>
      </motion.div>
    </div>
  );
}

useScroll, useTransform e useSpring retornam MotionValues — um tipo especial do Framer Motion que atualiza sem causar re-renders do React. Por isso, scroll-driven animations com Framer Motion são extremamente eficientes: o valor muda dezenas de vezes por segundo durante o scroll, mas o componente React não re-renderiza.

Page Transitions no Next.js

Transições suaves entre páginas transformam um site comum em uma experiência de app. No Next.js App Router, você implementa isso no layout raiz:

PageTransition.tsx
// Componente wrapper para ser usado no layout.tsx do Next.js
'use client';

import { motion, AnimatePresence } from 'framer-motion';
import { usePathname } from 'next/navigation';

const pageVariants = {
  initial: { opacity: 0, y: 8 },
  enter: {
    opacity: 1,
    y: 0,
    transition: { duration: 0.3, ease: [0.61, 1, 0.88, 1] },
  },
  exit: {
    opacity: 0,
    y: -8,
    transition: { duration: 0.2, ease: 'easeIn' },
  },
};

export function PageTransition({ children }: { children: React.ReactNode }) {
  const pathname = usePathname();

  return (
    <AnimatePresence mode="wait" initial={false}>
      <motion.main
        key={pathname} // Chave muda na navegação, forçando exit + enter
        variants={pageVariants}
        initial="initial"
        animate="enter"
        exit="exit"
      >
        {children}
      </motion.main>
    </AnimatePresence>
  );
}

// No layout.tsx:
// <PageTransition>{children}</PageTransition>

Performance: Mantendo 60fps

Animações mal escritas causam jank — frames perdidos que fazem a interface travar. A regra de ouro é: anime apenas propriedades compostas (composited properties), que são processadas pela GPU sem envolver o layout do browser:

  • ✅ `transform` (translateX, translateY, scale, rotate) — processado na GPU
  • ✅ `opacity` — processado na GPU
  • ❌ `width`, `height`, `margin`, `padding`, `top`, `left` — causam layout/reflow em cada frame, muito caro
  • ❌ `background-color`, `color`, `border` — causam repaint, moderadamente caro
PerformantAnimation.tsx
import { motion } from 'framer-motion';

// ❌ RUIM: animar width causa reflow em todo o layout a cada frame
const BadExpandButton = () => (
  <motion.div
    initial={{ width: 48 }}
    animate={{ width: 200 }}
    className="btn"
  >
    Expandir
  </motion.div>
);

// ✅ BOM: animar scaleX com transform-origin não afeta o layout
const GoodExpandButton = () => (
  <motion.div
    initial={{ scaleX: 0.24 }} // 48/200 = 0.24
    animate={{ scaleX: 1 }}
    style={{ transformOrigin: 'left center', width: 200 }}
    className="btn"
  >
    Expandir
  </motion.div>
);

// Dica: adicione will-change='transform' no CSS para elementos que
// serão animados frequentemente. O browser cria uma layer compositor
// dedicada, isolando a animação do resto do layout.
//
// Mas use com moderação: cada layer compositor consome memória de GPU.

Para identificar problemas de performance, abra o Chrome DevTools → Performance → grave uma interação e procure por Long Tasks (blocos vermelhos) e Layout Shift no painel de frames. O Framer Motion DevTools (extensão do Chrome) também mostra o estado de cada MotionValue em tempo real.

Gestures Avançados: Drag e Swipe

O Framer Motion inclui um sistema de gestures completo, com suporte a drag, pan e swipe com física. Útil para carrosséis, cards descartáveis (estilo Tinder) e UI mobile-first:

SwipeCard.tsx
import { motion, useMotionValue, useTransform, animate } from 'framer-motion';

interface SwipeCardProps {
  title: string;
  onSwipeLeft: () => void;
  onSwipeRight: () => void;
}

export function SwipeCard({ title, onSwipeLeft, onSwipeRight }: SwipeCardProps) {
  const x = useMotionValue(0);

  // Rotaciona o card proporcionalmente ao deslocamento horizontal
  const rotate = useTransform(x, [-200, 0, 200], [-25, 0, 25]);
  // Desbota o card ao se afastar do centro
  const opacity = useTransform(x, [-200, -100, 0, 100, 200], [0, 1, 1, 1, 0]);

  async function handleDragEnd() {
    const currentX = x.get();
    const THRESHOLD = 100;

    if (currentX > THRESHOLD) {
      // Swipe direita: lança o card para fora da tela
      await animate(x, 500, { type: 'spring', stiffness: 300, damping: 30 });
      onSwipeRight();
    } else if (currentX < -THRESHOLD) {
      // Swipe esquerda
      await animate(x, -500, { type: 'spring', stiffness: 300, damping: 30 });
      onSwipeLeft();
    } else {
      // Não passou do threshold: volta para o centro
      animate(x, 0, { type: 'spring', stiffness: 300, damping: 30 });
    }
  }

  return (
    <motion.div
      style={{ x, rotate, opacity }}
      drag="x"
      dragConstraints={{ left: 0, right: 0 }} // Fica ancorado ao centro
      dragElastic={0.8} // Resistência ao arrastar além dos constraints
      onDragEnd={handleDragEnd}
      className="swipe-card"
      whileDrag={{ cursor: 'grabbing' }}
    >
      <h3>{title}</h3>
    </motion.div>
  );
}

Conclusão

O Framer Motion tem uma curva de aprendizado progressiva: você começa com initial/animate em minutos e vai descobrindo features conforme a necessidade — Variants para consistência, AnimatePresence para unmount, MotionValues para performance, layout animations para reordenação. Cada camada adiciona expressividade sem comprometer a legibilidade do código.

A diferença entre uma animação mediana e uma premium está nos detalhes: usar spring ao invés de easing linear para interações do usuário, respeitar prefers-reduced-motion, animar apenas propriedades compostas e não animar o DOM diretamente via useEffect e style. Com o Framer Motion, a maioria dessas boas práticas já vem por padrão.