Communitygithub.com

KilianBStar/motion-patterns

Patrones de animación listos para producción para React / Next.js: botón, modal, notificación, escalonado, transiciones de página, animaciones de salida, scroll y diseño. Úsalos al animar un elemento de UI específico en React o Next.js.

¿Qué es motion-patterns?

Esta habilidad proporciona un conjunto completo de patrones de animación listos para producción y copiables para React y Next.js, diseñados para manejar los casos de uso de animación de UI más comunes. Desde animaciones simples de botones y tarjetas hasta transiciones complejas de página y listas escalonadas, cada patrón se basa en los tokens y springs de `motion-foundations`, lo que garantiza consistencia, accesibilidad y renderizado seguro para SSR. La habilidad cubre principios esenciales como envolver siempre los renders condicionales con `AnimatePresence` y una `key`, definir animaciones de entrada, animación y salida juntas, y usar valores de tokens para duración, easing y distancia en lugar de números fijos. También incluye guías de decisión detalladas para seleccionar el patrón correcto, reglas estrictas para evitar anti-patrones comunes y ejemplos completos de código para botones, modales, notificaciones, transiciones de página en Next.js App Router, revelaciones por scroll, acordeones y crossfades de elementos compartidos. Todo el código está diseñado para integrarse con `motion-foundations` para una configuración de animación centralizada y tipada.

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

Preguntar en tu IA favorita

Abre un nuevo chat con esta habilidad de agente ya precargada.

Documentación

Patrones de Movimiento (Motion Patterns)

Patrones de copiar y pegar para las necesidades de animación de UI más comunes. Cada patrón aquí está construido sobre los tokens y springs de motion-foundations. No definas nuevos valores de duración o easing aquí — impórtalos.

Cuándo Activar

  • Animando un botón, tarjeta, modal o notificación (toast)
  • Construyendo entradas de lista con escalonado (stagger)
  • Configurando transiciones de página en Next.js App Router
  • Añadiendo animaciones de entrada o salida a contenido condicional
  • Implementando revelaciones por scroll, progreso vinculado al scroll o secciones tipo historia (sticky story)
  • Construyendo tarjetas expandibles, acordeones o transiciones de elementos compartidos

Salidas

Esta habilidad produce:

  • Animaciones accesibles y seguras para SSR para todos los componentes de UI estándar
  • Renders condicionales envueltos en AnimatePresence con comportamiento de salida correcto
  • Componente envolvente de transición de página para Next.js App Router
  • Patrones de revelación por scroll y vinculados al scroll usando useScroll + useTransform
  • Patrones de animación de diseño (layout, layoutId) para expandir y fundir elementos

Principios

  • Cada patrón importa de motion-foundations. Sin números concretos.
  • Cada render condicional está envuelto en AnimatePresence con una key.
  • Las animaciones de salida siempre se definen junto con las de entrada — nunca como una ocurrencia tardía.
  • layout se usa solo para cambios pequeños y aislados. Los subárboles grandes obtienen transformaciones explícitas.

Reglas

  1. Siempre envuelve los renders condicionales en AnimatePresence con una key en el hijo directo. Sin una key, las animaciones de salida nunca se ejecutan.
  2. Siempre define exit cuando definas initial + animate. Una animación sin salida está incompleta.
  3. Usa mode="wait" en transiciones de página. La entrada no debe comenzar hasta que la salida se complete.
  4. Nunca uses layout en subárboles con más de ~5 hijos o DOM profundamente anidado. Usa transformaciones explícitas de x/y.
  5. El intervalo de escalonado (stagger) debe permanecer entre 0.05s y 0.10s. Por debajo se siente mecánico; por encima se siente lento.
  6. Los modales siempre deben incluir: trampa de foco (focus trap), cierre con tecla Escape, bloqueo de scroll, role="dialog", aria-modal="true".
  7. Las revelaciones por scroll usan viewport={{ once: true }}. Repetir al salir del viewport distrae, no informa.
  8. Todos los valores de tokens se importan de motion-foundations. Sin números en línea.

Guía de Decisión

Elegir el patrón correcto

SituaciónPatrón
El elemento aparece / desapareceAnimatePresence
Lista de elementos cargando en secuenciaVariantes con Stagger
Navegando entre rutasEnvolvente de transición de página
El elemento cambia de tamaño en su lugarPropiedad layout
El mismo elemento se mueve entre contextos de páginalayoutId
El elemento entra al hacer scroll hasta élwhileInView
Valor vinculado a la posición de scrolluseScroll + useTransform

Cuándo usar mode="wait" vs mode="sync"

ModoÚsalo cuando
waitTransiciones de página, intercambios de contenido (uno a la vez)
syncNotificaciones apiladas, elementos de lista (la superposición está bien)
popLayoutElementos eliminados de una lista con reflujo

Conceptos Clave

Contrato de AnimatePresence

Tres cosas siempre deben ser ciertas:

  1. AnimatePresence envuelve el condicional
  2. El hijo directo tiene una key
  3. El hijo tiene una propiedad exit

Si falta alguna de estas, la animación de salida falla silenciosamente.

layout vs layoutId

  • layout — anima el propio cambio de tamaño/posición del elemento en su lugar
  • layoutId — vincula dos elementos separados, fundiéndolos entre renders

Usa layout="position" en el texto dentro de un contenedor en expansión para evitar que el reflujo de texto se anime.

Ejemplos de Código

Feedback de botón

"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 escalonada (Stagger)

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

const container = {
  hidden: {},
  visible: {
    transition: {
      staggerChildren: 0.08,   // dentro de la regla 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"

// Envuelve en el lugar de llamada:
// <AnimatePresence>{isOpen && <Modal key="modal" />}</AnimatePresence>

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

      {/* Panel — requisitos de accesibilidad: trampa de foco, cierre con Escape,
          bloqueo 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}
      />
    </>
  )
}

Pila de notificaciones (Toast stack)

"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>

Transición 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>
  )
}

Revelación por 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 — regla 7
  transition={{ duration: motionTokens.duration.slow, ease: motionTokens.easing.smooth }}
/>

Barra de progreso de 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 }}
    />
  )
}

Tarjeta expandible

"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" evita que el reflujo de texto se anime */}
      <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>
  )
}

Fundido de elemento compartido (Shared-element crossfade)

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

// Contexto de destino (mismo layoutId — motion maneja la transición)
<motion.img layoutId="hero-image" src={src} className="w-full rounded-xl" />

Acordeón

<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>

Ejemplo Integral

Una lista escalonada que entra al montarse, maneja presencia condicional y respeta el movimiento reducido — combinando tokens, springs, AnimatePresence y el hook de accesibilidad de 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>
  )
}

Restricciones / No Objetivos

Esta habilidad no cubre:

  • Definiciones de tokens y springs → consulta motion-foundations
  • Interacciones de arrastre, gestos de deslizamiento, listas reordenables → consulta motion-advanced
  • Animaciones de texto (revelación de palabra/carácter, contadores) → consulta motion-advanced
  • Dibujo o transformación de rutas SVG → consulta motion-advanced
  • Hooks de animación personalizados → consulta motion-advanced
  • Transiciones solo CSS que no usen motion/react

Anti-patrones

Anti-patrónRegla violadaSolución
Hijo de AnimatePresence sin keyRegla 1Añade una key estable al hijo directo
initial + animate sin exitRegla 2Siempre define los tres juntos
Transición de página sin mode="wait"Regla 3Añade mode="wait" a AnimatePresence
layout en una lista de 50 elementosRegla 4Usa mode="popLayout" o transformaciones explícitas
staggerChildren: 0.2 en una lista de 10 elementosRegla 5Limita a 0.08–0.10
Modal sin trampa de focoRegla 6Añade focus-trap-react o Radix Dialog
whileInView sin viewport={{ once: true }}Regla 7Las entradas repetitivas distraen, no informan
transition={{ duration: 0.3 }} en líneaRegla 8Usa motionTokens.duration.normal

Habilidades Relacionadas

  • motion-foundations — define todos los tokens, springs, el hook useSafeMotion y las protecciones SSR que cada patrón aquí importa. Debe configurarse primero.
  • motion-advanced — extiende estos patrones con arrastre, gestos, SVG, texto, hooks personalizados y secuenciación imperativa. No redefine ningún patrón de esta habilidad.

Skills relacionados