En el pipeline de renderizado del navegador (Critical Rendering Path), las operaciones que mutan parámetros geométricos (width, height, top, left, margin, padding) son las más costosas y perjudiciales para el rendimiento. Cualquier alteración de geometría obliga al navegador a ejecutar un recálculo en cascada de las coordenadas de cada elemento del documento: Layout (Reflow).
Alternar lecturas síncronas de dimensiones (element.offsetHeight, getBoundingClientRect) con escrituras geométricas posteriores en un único fotograma desencadena Layout Thrashing. En consecuencia, la tasa de refresco cae de 120 FPS a unos entrecortados 20–30 FPS, y la latencia de interacción del usuario (INP) se degrada a la zona crítica (> 200 ms).
El motor moderno Motion (a partir de la versión 11+, Framer Motion evolucionó al paquete modular motion) redefine por completo el paradigma de animación de maquetación. En lugar de mutar propiedades de geometría, Motion implementa el patrón fundamental FLIP (First, Last, Invert, Play). La librería mide la posición inicial y final de los nodos DOM, invierte la diferencia mediante matrices de transformación e interpola exclusivamente mediante la GPU en el hilo del compositor (Compositor Thread) sin coste de Reflow.
Las transiciones CSS tradicionales sobre width/height o top/left sobrecargan inevitablemente la CPU con cálculos de maquetación en cada fotograma. El motor Motion traduce cualquier cambio de layout (inserción de elementos, filtrado de listas, transiciones de elementos compartidos) en transformaciones de GPU aceleradas por hardware (transform y opacity), garantizando 60–120 FPS estables y respuesta táctil instantánea a los gestos.
El núcleo de Motion reside en el pipeline de cuatro fases del algoritmo FLIP acoplado a un simulador físico de resortes (Spring Physics):
┌────────────────────────────────────────────────────────────────────────┐
│ PIPELINE MATEMÁTICO DE LA TÉCNICA FLIP │
├────────────────────────────────────────────────────────────────────────┤
│ 1. FIRST: Medición de coordenadas iniciales: box1 = el.getBCR() │
│ 2. LAST: Aplicación del nuevo estado en el DOM: box2 = el.getBCR() │
│ 3. INVERT: Cálculo de la matriz delta de desplazamiento y escala: │
│ dx = box1.left - box2.left; sx = box1.width / box2.width │
│ Aplicación de inversión: transform: translate(dx) scale(sx)│
│ 4. PLAY: Interpolación hacia transform: identity en la GPU │
└───────────────────────────────────┬────────────────────────────────────┘
│
▼ (Compositor Thread)
┌────────────────────────────────────────────────────────────────────────┐
│ Resultado: 120 FPS, cero recálculos de Layout, fluidez por hardware │
└────────────────────────────────────────────────────────────────────────┘layout: Prop que indica a Motion monitorizar los cambios geométricos del nodo. Si una tarjeta cambia de tamaño o posición debido a que un elemento hermano se añade a un contenedor Flexbox/Grid, Motion suaviza la transición automáticamente mediante FLIP.if (isOpen)) y gestiona la fase exit.cubic-bezier, el resorte conserva el vector de velocidad de lanzamiento (velocity) cuando el usuario interrumpe un gesto.El usuario pulsa una tarjeta: setSelectedId(42). React 19 planifica el renderizado concurrente del nuevo árbol de interfaz.
Motion mide de forma síncrona las dimensiones de la miniatura (First), monta la tarjeta completa y registra sus coordenadas finales (Last).
Motion calcula el delta de scale y translate y lo aplica mediante CSS transform al nuevo elemento, devolviéndolo visualmente a la miniatura.
El integrador de resorte interpola la matriz de transformación hacia la identidad fotograma a fotograma en el Compositor Thread a 120 FPS.
¿Por qué animar la posición y el tamaño de un elemento mediante la prop layout en Motion se ejecuta de forma mucho más fluida (120 FPS) que una transición CSS tradicional como transition: width 0.3s, left 0.3s?
En aplicaciones móviles y web, pulsar un producto no debe causar parpadeos ni pantallas en blanco. La tarjeta se expande fluidamente desde la cuadrícula a un diálogo a pantalla completa, conservando el foco, la continuidad visual y desvaneciendo progresivamente los detalles adicionales.
Al alternar entre pestañas de navegación, el marcador activo no salta abruptamente; se desliza bajo el elemento seleccionado compartiendo un único layoutId.
Al arrastrar una tarea, las tarjetas contiguas se apartan de forma natural para ceder espacio. Aplicar layout="position" garantiza latencia cero en el reposicionamiento al renderizar tableros extensos.
Hojas modales y notificaciones que el usuario puede arrastrar interactivamente. Al soltar el panel con gran velocidad hacia abajo, el integrador de resortes aprovecha el impulso acumulado (velocity) para cerrar la vista con autenticidad física.
En las animaciones CSS clásicas, el tiempo es rígido: transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1). Si el usuario interrumpe un gesto a mitad de movimiento, CSS reinicia la velocidad a cero o genera un salto visual abrupto.
Motion modela el movimiento mediante la ecuación de un oscilador armónico con resorte:
F = -k * x - c * vDonde:
k (stiffness, rigidez): fuerza con la que el resorte atrae el objeto hacia el punto de destino. A mayor rigidez, mayor aceleración;c (damping, amortiguación): resistencia por fricción. Por debajo del umbral crítico, se producen oscilaciones elásticas (rebote o bounce);m (mass, masa): inercia del cuerpo en movimiento;v (velocity, velocidad inicial): vector de velocidad del dedo o cursor al soltar el elemento.Al escalar mediante transform: scale(), la tipografía hija se deforma y se desenfoca, y los radios de esquina (border-radius) adoptan formas elípticas. Motion resuelve esto desacoplando responsabilidades espaciales:
[Contenedor Padre]: layout (anima dimensiones y posición mediante FLIP)
│
└── [Texto Hijo]: layout="position" (compensa la escala del padre,
desplazándose sin distorsión de fuentes)En una pila de notificaciones, al descartar una tarjeta mediante clic, las tarjetas inferiores deben elevarse de forma inmediata y suave para ocupar el espacio libre mientras la tarjeta descartada se desvanece y vuela hacia la derecha. ¿Qué modo de AnimatePresence debe utilizarse?
Desarrollemos dos componentes de alto rendimiento listos para producción en React 19:
src/components/navigation/SlidingTabsNav.tsx: Pestañas de navegación con indicador físico deslizante basado en layoutId y cero Layout Thrashing.src/components/gallery/ExpandableCardModal.tsx: Galería de producción con Shared Element Transitions, física de resortes para gestos de cierre por arrastre y compensación de distorsión tipográfica.'use client';
import * as React from 'react';
import { motion } from 'motion/react';
export interface TabItem {
readonly id: string;
readonly label: string;
readonly badge?: number;
}
interface SlidingTabsNavProps {
readonly tabs: readonly TabItem[];
readonly defaultTabId?: string;
readonly onChange?: (tabId: string) => void;
}
export function SlidingTabsNav({
tabs,
defaultTabId,
onChange,
}: SlidingTabsNavProps) {
const [activeTab, setActiveTab] = React.useState<string>(
defaultTabId ?? tabs[0]?.id ?? ''
);
const handleSelect = (id: string) => {
setActiveTab(id);
onChange?.(id);
};
return (
<nav aria-label="Pestañas de secciones" className="inline-flex p-1.5 rounded-2xl bg-bg-surface border border-border shadow-inner">
<div className="flex items-center gap-1">
{tabs.map((tab) => {
const isActive = tab.id === activeTab;
return (
<button
key={tab.id}
type="button"
onClick={() => handleSelect(tab.id)}
className={`relative px-4 py-2 text-xs font-semibold rounded-xl transition-colors duration-200 outline-none focus-visible:ring-2 focus-visible:ring-accent-violet cursor-pointer ${
isActive ? 'text-white' : 'text-text-muted hover:text-text-primary'
}`}
>
{/* Marcador flotante de pestaña activa en GPU */}
{isActive && (
<motion.div
layoutId="active-tab-pill-marker"
className="absolute inset-0 rounded-xl bg-accent-violet/30 border border-accent-violet/50 shadow-sm"
transition={{
type: 'spring',
stiffness: 450,
damping: 32,
mass: 0.8,
}}
/>
)}
{/* Contenido de la pestaña */}
<span className="relative z-10 flex items-center gap-2">
<span>{tab.label}</span>
{typeof tab.badge === 'number' && (
<span
className={`text-[10px] font-mono px-1.5 py-0.5 rounded-full ${
isActive
? 'bg-accent-violet text-white'
: 'bg-bg-elevated text-text-muted'
}`}
>
{tab.badge}
</span>
)}
</span>
</button>
);
})}
</div>
</nav>
);
}'use client';
import * as React from 'react';
import { motion, AnimatePresence, type PanInfo } from 'motion/react';
export interface CardItem {
readonly id: string;
readonly title: string;
readonly category: string;
readonly description: string;
readonly tag: string;
}
const SAMPLE_CARDS: readonly CardItem[] = [
{
id: 'card-1',
title: 'React 19 Concurrent Architecture',
category: 'Arquitectura',
tag: 'Fiber & Lanes',
description: 'Análisis profundo del planificador React Fiber, prioridades de ejecución por carriles (Lanes) e hidratación selectiva.',
},
{
id: 'card-2',
title: 'Motion FLIP Engine',
category: 'Gráficos',
tag: 'Compositor 120 FPS',
description: 'Pipeline matemático de transformaciones en el Compositor Thread del navegador con cero recálculo de maquetación en el DOM.',
},
{
id: 'card-3',
title: 'Local-First & CRDT Replication',
category: 'Bases de datos',
tag: 'Yjs & Dexie',
description: 'Sincronización de documentos sin conflictos entre almacenes IndexedDB del navegador mediante WebSockets y WebRTC.',
},
];
export function ExpandableCardModal() {
const [selectedId, setSelectedId] = React.useState<string | null>(null);
const activeCard = SAMPLE_CARDS.find((c) => c.id === selectedId);
// Cierre del modal por arrastre rápido hacia abajo
const handleDragEnd = (_event: MouseEvent | TouchEvent | PointerEvent, info: PanInfo) => {
if (info.offset.y > 100 || info.velocity.y > 400) {
setSelectedId(null);
}
};
return (
<div className="relative max-w-4xl mx-auto p-6 space-y-6">
{/* Cuadrícula de miniaturas */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-5">
{SAMPLE_CARDS.map((card) => (
<motion.div
key={card.id}
layoutId={`card-container-${card.id}`}
onClick={() => setSelectedId(card.id)}
whileHover={{ y: -4, transition: { duration: 0.15 } }}
whileTap={{ scale: 0.98 }}
className="cursor-pointer p-6 rounded-2xl bg-bg-surface border border-border hover:border-accent-violet/40 transition-colors shadow-lg space-y-3"
transition={{
type: 'spring',
stiffness: 350,
damping: 28,
}}
>
<motion.div
layoutId={`card-badge-${card.id}`}
className="inline-block text-[10px] font-mono font-bold uppercase tracking-wider px-2 py-0.5 rounded-full bg-accent-violet/15 text-accent-violet border border-accent-violet/20"
>
{card.category}
</motion.div>
{/* layout='position' evita la distorsión tipográfica durante el escalado del padre */}
<motion.h3
layout="position"
layoutId={`card-title-${card.id}`}
className="font-bold text-base text-text-primary leading-snug"
>
{card.title}
</motion.h3>
<motion.p
layout="position"
className="text-xs text-text-secondary line-clamp-2"
>
{card.description}
</motion.p>
</motion.div>
))}
</div>
{/* Overlay modal con transición de elemento compartido */}
<AnimatePresence>
{activeCard && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
{/* Fondo semitransparente */}
<motion.div
key="backdrop"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={() => setSelectedId(null)}
className="absolute inset-0 bg-black/75 backdrop-blur-md cursor-pointer"
/>
{/* Tarjeta expandida con gesto de arrastre para cerrar */}
<motion.div
layoutId={`card-container-${activeCard.id}`}
drag="y"
dragConstraints={{ top: 0, bottom: 0 }}
dragElastic={0.6}
onDragEnd={handleDragEnd}
className="relative z-10 w-full max-w-lg p-8 rounded-3xl bg-bg-surface border border-accent-violet/40 shadow-2xl space-y-6 touch-none"
transition={{
type: 'spring',
stiffness: 300,
damping: 30,
}}
>
{/* Barra indicadora para el arrastre */}
<div className="w-12 h-1.5 rounded-full bg-border mx-auto -mt-2 opacity-60" />
<div className="flex items-start justify-between gap-4">
<div className="space-y-2">
<motion.div
layoutId={`card-badge-${activeCard.id}`}
className="inline-block text-[10px] font-mono font-bold uppercase tracking-wider px-2.5 py-0.5 rounded-full bg-accent-violet/15 text-accent-violet border border-accent-violet/20"
>
{activeCard.category} • {activeCard.tag}
</motion.div>
<motion.h2
layout="position"
layoutId={`card-title-${activeCard.id}`}
className="text-2xl font-black text-text-primary tracking-tight"
>
{activeCard.title}
</motion.h2>
</div>
<button
type="button"
onClick={() => setSelectedId(null)}
className="px-3 py-1.5 text-xs font-mono rounded-xl bg-bg-elevated border border-border text-text-muted hover:text-text-primary transition-colors cursor-pointer"
>
ESC
</button>
</div>
{/* Contenido detallado que aparece progresivamente tras la expansión */}
<motion.div
initial={{ opacity: 0, y: 15 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 5 }}
transition={{ delay: 0.08, duration: 0.2 }}
className="space-y-4 pt-4 border-t border-border text-sm text-text-secondary leading-relaxed"
>
<p>{activeCard.description}</p>
<p className="text-xs text-text-muted">
Arrastra la tarjeta hacia abajo para cerrar o pulsa la tecla ESC / haz clic en el fondo. Animación ejecutada mediante el algoritmo FLIP sin recálculo de maquetación.
</p>
</motion.div>
</motion.div>
</div>
)}
</AnimatePresence>
</div>
);
}Intentar animar manualmente animate={{ width: isOpen ? 400 : 100 }} obliga al navegador a ejecutar una costosa fase de Layout en cada tick de animación.
Solución: Utiliza la directiva layout y alterna clases de tamaño en Tailwind, permitiendo que Motion traduzca los cambios a matrices FLIP aceleradas por GPU.
Si los elementos dentro de AnimatePresence carecen de una prop key única y estable, React Fiber no puede asociar el nodo saliente, haciendo que desaparezca de inmediato sin reproducir su fase exit.
Solución: Especifica siempre una key única para todos los hijos animados bajo AnimatePresence.
Dado que FLIP emplea transform: scale(), el texto anidado se estira y se desenfoca durante las transiciones morfológicas.
Solución: Añade siempre layout="position" a los encabezados y párrafos de texto hijos para que se desplacen sin verse afectados por el escalado del padre.
Importar import { motion } from 'framer-motion' arrastra un paquete monolítico heredado que supera los 35 KB.
Solución: En proyectos con Framer Motion 11+, utiliza el punto de entrada optimizado y modular: import { motion } from 'motion/react'.
transform: translate / scale) en el Compositor Thread.layoutId vincula componentes independientes en el árbol de React, proporcionando transiciones cinéticas continuas (Shared Element Transitions) entre tarjetas de catálogo y modales.AnimatePresence coordina las fases de salida (exit), y su modo mode="popLayout" permite a los elementos adyacentes ocupar de inmediato el espacio liberado mediante position: absolute.stiffness), la amortiguación (damping) y la masa (mass), preservando de forma nativa la velocidad del gesto (velocity).layout="position", y la importación desde motion/react garantiza un bundle óptimo.