Communitygithub.com

KilianBStar/motion-patterns

Padrões de animação prontos para produção em React / Next.js — botão, modal, toast, escalonamento, transições de página, animações de saída, scroll e layout — construídos sobre tokens e molas do motion-foundations. Use ao animar um elemento específico de UI em React ou Next.js — botão, modal, toast, escalonamento, transição de página ou scroll.

O que é motion-patterns?

Esta skill oferece um conjunto de padrões de animação copiáveis e prontos para produção, projetados para as necessidades mais comuns de UI em React e Next.js. Ela se baseia exclusivamente nos tokens e molas definidos pelo pacote `motion-foundations`, garantindo consistência visual e desempenho. A skill se ativa automaticamente quando o desenvolvedor precisa animar elementos como botões, modais, notificações toast, listas escalonadas, transições entre páginas no App Router do Next.js e animações baseadas em scroll. Todos os padrões seguem princípios rigorosos de acessibilidade e desempenho, como o uso de `AnimatePresence` com `key` para animações de saída, modais acessíveis com foco e bloqueio de scroll, e animações de scroll com exibição única. A documentação inclui regras claras, uma tabela de tomada de decisão para escolher o padrão correto, exemplos de código completos e uma seção de anti-padrões que ajuda a evitar erros comuns. É suportada em qualquer projeto que utilize React, Next.js e `motion/react`.

Funciona com~Claude Code~Codex CLI✓Cursor
npx skills add https://github.com/KilianBStar/mes-skills-/tree/HEAD/.claude/skills/motion-patterns

Perguntar na sua IA favorita

Abre um novo chat com esta habilidade de agente já pré-carregada.

Documentação

Motion Patterns

Padrões copiáveis para as necessidades mais comuns de animação de UI. Cada padrão aqui é construído sobre tokens e molas do motion-foundations. Não defina novos valores de duração ou easing aqui — importe-os.

Quando Ativar

  • Animando um botão, card, modal ou notificação toast
  • Construindo entradas de lista com escalonamento (stagger)
  • Configurando transições de página no App Router do Next.js
  • Adicionando animações de entrada ou saída a conteúdo condicional
  • Implementando revelação ao scroll, progresso vinculado ao scroll ou seções de história fixas
  • Construindo cartões expansíveis, acordeões ou transições de elemento compartilhado

Saídas

Esta skill produz:

  • Animações acessíveis e seguras para SSR para todos os componentes padrão de UI
  • Renderizações condicionais encapsuladas em AnimatePresence com comportamento de saída correto
  • Componente wrapper de transição de página para o App Router do Next.js
  • Padrões de revelação ao scroll e vinculados ao scroll usando useScroll + useTransform
  • Padrões de animação de layout (layout, layoutId) para elementos expansíveis e crossfades

Princípios

  • Cada padrão importa do motion-foundations. Sem números brutos.
  • Toda renderização condicional é encapsulada em AnimatePresence com uma key.
  • Animações de saída são sempre definidas junto com animações de entrada — nunca como um pensamento tardio.
  • layout é usado apenas para pequenas mudanças isoladas. Subárvores grandes recebem transformações explícitas.

Regras

  1. Sempre encapsule renderizações condicionais em AnimatePresence com uma key no filho direto. Sem uma chave, animações de saída nunca são executadas.
  2. Sempre defina exit ao definir initial + animate. Uma animação sem saída é incompleta.
  3. Use mode="wait" em transições de página. A entrada não deve começar até que a saída termine.
  4. Nunca use layout em subárvores com mais de ~5 filhos ou DOM profundamente aninhado. Use transformações explícitas x/y.
  5. O intervalo de escalonamento (stagger) deve ficar entre 0.05s e 0.10s. Abaixo disso parece mecânico; acima parece lento.
  6. Modais devem sempre incluir: armadilha de foco, fechamento com tecla Escape, bloqueio de scroll, role="dialog", aria-modal="true".
  7. Revelações ao scroll usam viewport={{ once: true }}. Repetir ao sair da tela é distrativo, não informativo.
  8. Todos os valores de token são importados do motion-foundations. Sem números inline.

Guia de Decisão

Escolhendo o padrão certo

SituaçãoPadrão
Elemento aparece / desapareceAnimatePresence
Lista de itens carregando em sequênciaVariantes com escalonamento (stagger)
Navegando entre rotasWrapper de transição de página
Elemento muda de tamanho no lugarPropriedade layout
Mesmo elemento se move entre contextos de páginalayoutId
Elemento entra quando visto no scrollwhileInView
Valor vinculado à posição do scrolluseScroll + useTransform

Quando usar mode="wait" vs mode="sync"

ModoUse quando
waitTransições de página, trocas de conteúdo (um de cada vez)
syncNotificações empilhadas, itens de lista (sobreposição é aceitável)
popLayoutItens removidos de uma lista com reflow

Conceitos Principais

Contrato do AnimatePresence

Três coisas devem ser sempre verdadeiras:

  1. AnimatePresence encapsula o condicional
  2. O filho direto tem uma key
  3. O filho tem uma propriedade exit

Se qualquer uma destas faltar, a animação de saída falha silenciosamente.

layout vs layoutId

  • layout — anima a própria mudança de tamanho/posição do elemento no lugar
  • layoutId — vincula dois elementos separados, fazendo um crossfade entre eles em diferentes renderizações

Use layout="position" em texto dentro de um contêiner expansível para evitar que o reflow do texto seja animado.

Exemplos de Código

Feedback de botão

"use client"
import { motion } from "motion/react"
import { springs, motionTokens } from "@/lib/motion-tokens"

<motion.button
  whileHover={{ scale: motionTokens.scale.pop }}
  whileTap={{ scale: motionTokens.scale.press }}
  transition={springs.snappy}
/>

Lista com escalonamento (stagger)

"use client"
import { motion } from "motion/react"
import { motionTokens, springs } from "@/lib/motion-tokens"

const container = {
  hidden: {},
  visible: {
    transition: {
      staggerChildren: 0.08,   // dentro da regra 0.05–0.10
      delayChildren: 0.1,
    },
  },
}

const item = {
  hidden:  { opacity: 0, y: motionTokens.distance.md },
  visible: { opacity: 1, y: 0, transition: springs.gentle },
}

<motion.ul variants={container} initial="hidden" animate="visible">
  {items.map((i) => (
    <motion.li key={i.id} variants={item} />
  ))}
</motion.ul>

Modal

"use client"
import { motion, AnimatePresence } from "motion/react"
import { motionTokens, springs } from "@/lib/motion-tokens"

// Encapsule no local de chamada:
// <AnimatePresence>{isOpen && <Modal key="modal" />}</AnimatePresence>

export function Modal({ onClose }: { onClose: () => void }) {
  return (
    <>
      {/* Overlay */}
      <motion.div
        className="fixed inset-0 bg-black/50"
        initial={{ opacity: 0 }}
        animate={{ opacity: 1 }}
        exit={{ opacity: 0 }}
        onClick={onClose}
      />

      {/* Painel — requisitos de acessibilidade: armadilha de foco, fechamento com Escape,
          bloqueio de scroll, role="dialog", aria-modal="true" */}
      <motion.div
        role="dialog"
        aria-modal="true"
        className="fixed inset-x-4 top-1/2 -translate-y-1/2 rounded-xl bg-white p-6"
        initial={{
          opacity: 0,
          scale: motionTokens.scale.press,
          y: motionTokens.distance.sm,
        }}
        animate={{ opacity: 1, scale: 1, y: 0 }}
        exit={{
          opacity: 0,
          scale: motionTokens.scale.press,
          y: motionTokens.distance.sm,
        }}
        transition={springs.gentle}
      />
    </>
  )
}

Pilha de toasts

"use client"
import { motion, AnimatePresence } from "motion/react"
import { motionTokens, springs } from "@/lib/motion-tokens"

<AnimatePresence mode="sync">
  {toasts.map((t) => (
    <motion.div
      key={t.id}
      layout
      initial={{
        opacity: 0,
        x: motionTokens.distance.xl,
        scale: motionTokens.scale.subtle,
      }}
      animate={{ opacity: 1, x: 0, scale: 1 }}
      exit={{
        opacity: 0,
        x: motionTokens.distance.xl,
        scale: motionTokens.scale.subtle,
      }}
      transition={springs.snappy}
    />
  ))}
</AnimatePresence>

Transição de página (Next.js App Router)

// components/page-transition.tsx
"use client"
import { motion, AnimatePresence } from "motion/react"
import { usePathname } from "next/navigation"
import { motionTokens } from "@/lib/motion-tokens"

const variants = {
  initial: { opacity: 0, y: motionTokens.distance.sm },
  enter:   { opacity: 1, y: 0 },
  exit:    { opacity: 0, y: -motionTokens.distance.sm },
}

export function PageTransition({ children }: { children: React.ReactNode }) {
  const pathname = usePathname()
  return (
    <AnimatePresence mode="wait">
      <motion.div
        key={pathname}
        variants={variants}
        initial="initial"
        animate="enter"
        exit="exit"
        transition={{
          duration: motionTokens.duration.normal,
          ease: motionTokens.easing.smooth,
        }}
      >
        {children}
      </motion.div>
    </AnimatePresence>
  )
}

Revelação ao scroll

"use client"
import { motion } from "motion/react"
import { motionTokens, springs } from "@/lib/motion-tokens"

<motion.div
  initial={{ opacity: 0, y: motionTokens.distance.lg }}
  whileInView={{ opacity: 1, y: 0 }}
  viewport={{ once: true, margin: "-80px" }}   // once: true — regra 7
  transition={{ duration: motionTokens.duration.slow, ease: motionTokens.easing.smooth }}
/>

Barra de progresso do scroll

"use client"
import { motion, useScroll } from "motion/react"

export function ScrollProgress() {
  const { scrollYProgress } = useScroll()
  return (
    <motion.div
      className="fixed top-0 left-0 h-1 bg-indigo-500 origin-left w-full"
      style={{ scaleX: scrollYProgress }}
    />
  )
}

Cartão expansível

"use client"
import { useState } from "react"
import { motion, AnimatePresence } from "motion/react"
import { springs, motionTokens } from "@/lib/motion-tokens"

export function ExpandingCard({ title, body }: { title: string; body: string }) {
  const [expanded, setExpanded] = useState(false)

  return (
    <motion.div layout onClick={() => setExpanded(!expanded)} className="cursor-pointer">
      {/* layout="position" impede que o reflow do texto seja animado */}
      <motion.h2 layout="position" className="font-semibold">
        {title}
      </motion.h2>

      <AnimatePresence>
        {expanded && (
          <motion.p
            key="body"
            initial={{ opacity: 0 }}
            animate={{ opacity: 1 }}
            exit={{ opacity: 0 }}
            transition={{ duration: motionTokens.duration.fast }}
          >
            {body}
          </motion.p>
        )}
      </AnimatePresence>
    </motion.div>
  )
}

Crossfade de elemento compartilhado

// Contexto de origem
<motion.img layoutId="hero-image" src={src} className="w-16 h-16 rounded" />

// Contexto de destino (mesmo layoutId — motion gerencia a transição)
<motion.img layoutId="hero-image" src={src} className="w-full rounded-xl" />

Acordeão

<motion.div
  initial={false}
  animate={{ opacity: open ? 1 : 0, scaleY: open ? 1 : 0 }}
  style={{ transformOrigin: "top", overflow: "hidden" }}
  transition={{
    duration: motionTokens.duration.normal,
    ease: motionTokens.easing.smooth,
  }}
> {children}
</motion.div>

Exemplo Completo

Uma lista com escalonamento que entra na montagem, lida com presença condicional e respeita movimento reduzido — combinando tokens, molas, AnimatePresence e o hook de acessibilidade do motion-foundations:

"use client"
import { useState } from "react"
import { motion, AnimatePresence } from "motion/react"
import { motionTokens, springs } from "@/lib/motion-tokens"
import { useSafeMotion } from "@/hooks/use-reduced-motion"

const containerVariants = {
  hidden: {},
  visible: {
    transition: { staggerChildren: 0.08, delayChildren: 0.1 },
  },
}

function ListItem({ label, onRemove }: { label: string; onRemove: () => void }) {
  const safe = useSafeMotion(motionTokens.distance.sm)
  return (
    <motion.li
      variants={{
        hidden:  safe.initial,
        visible: safe.animate,
      }}
      exit={safe.exit}
      transition={springs.gentle}
      className="flex items-center justify-between p-3 rounded-lg bg-white shadow-sm"
    >
      <span>{label}</span>
      <button onClick={onRemove}>Remove</button>
    </motion.li>
  )
}

export function AnimatedList({ items, onRemove }: {
  items: { id: string; label: string }[]
  onRemove: (id: string) => void
}) {
  return (
    <motion.ul
      variants={containerVariants}
      initial="hidden"
      animate="visible"
      className="space-y-2"
    >
      <AnimatePresence mode="popLayout">
        {items.map((item) => (
          <ListItem
            key={item.id}
            label={item.label}
            onRemove={() => onRemove(item.id)}
          />
        ))}
      </AnimatePresence>
    </motion.ul>
  )
}

Restrições / Não-Objetivos

Esta skill não cobre:

  • Definições de tokens e molas → veja motion-foundations
  • Interações de arrastar, gestos de deslizar, listas reordenáveis → veja motion-advanced
  • Animações de texto (revelação de palavras/caracteres, contadores) → veja motion-advanced
  • Desenho ou morphing de caminho SVG → veja motion-advanced
  • Hooks de animação personalizados → veja motion-advanced
  • Transições apenas CSS que não usam motion/react

Anti-Padrões

Anti-padrãoRegra violadaCorreção
Filho de AnimatePresence sem keyRegra 1Adicione key estável ao filho direto
initial + animate sem exitRegra 2Sempre defina todos os três juntos
Transição de página sem mode="wait"Regra 3Adicione mode="wait" ao AnimatePresence
layout em uma lista de 50 itensRegra 4Use mode="popLayout" ou transformações explícitas
staggerChildren: 0.2 em uma lista de 10 itensRegra 5Limite em 0.08–0.10
Modal sem armadilha de focoRegra 6Adicione focus-trap-react ou Radix Dialog
whileInView sem viewport={{ once: true }}Regra 7Entradas repetidas distraem, não informam
transition={{ duration: 0.3 }} inlineRegra 8Use motionTokens.duration.normal

Skills Relacionadas

  • motion-foundations — define todos os tokens, molas, o hook useSafeMotion e as proteções SSR que cada padrão aqui importa. Deve ser configurado primeiro.
  • motion-advanced — estende estes padrões com arrastar, gestos, SVG, texto, hooks personalizados e sequenciamento imperativo. Não redefine nenhum padrão desta skill.

Habilidades Relacionadas