React Animation
El plugin instalado no tiene docs/react/ — si no se puede abrir la ruta docs/... de este archivo, se debe leer agregando la misma ruta después de https://raw.githubusercontent.com/joo6077/claude-plugins/main/. Si aún así no se puede leer, se debe indicar que no se pudo leer sin inventar el contenido.
Advertencias
-
Principio de cero bibliotecas — sin excepciones: Motion (framer-motion) / dnd-kit / react-spring / react-transition-group / @formkit/auto-animate / react-dnd / gsap / lottie-react / react-beautiful-dnd / animate.css están prohibidos de instalar e importar. Si estas importaciones existen en el código base,
/react-auditemitirá un fallo de compilación. Incluso si el usuario lo solicita, se debe sugerir una implementación alternativa y rechazar el uso de la biblioteca. -
Probar desde el nivel más bajo: Al recibir una solicitud de "animación", es común cometer el error de usar directamente el Tier 3. Seguir las reglas de decisión: verificar primero si se puede resolver con Tier 1, y solo subir a un Tier superior si no es posible.
-
Verificar el plugin
tailwindcss-animate: Las utilidades extendidas comoanimate-in,fade-in-*,slide-in-from-*requierentailwindcss-animate. En proyectos con shadcn instalado, suele estar incluido por defecto, pero si no, se debe guiar al usuario para ejecutarpnpm add -D tailwindcss-animatey registrar el plugin entailwind.config.ts. -
Unicidad de
view-transition-name: Si dos elementos tienen el mismo nombre simultáneamente, se produce el error "duplicate name". Se debe usar un id dinámico (view-transition-name: task-${item.id}) para garantizar la unicidad. -
Manejo obligatorio de Pointer cancel: Si no se maneja el evento
pointercancel, el estado de arrastre puede filtrarse al cambiar de pestaña, enfocar otra aplicación o desmontar el elemento. En el manejadoronPointerCancel, se debe reiniciar la FSM aidle. -
Si falta
touch-action: none, el arrastre móvil no funciona: En elementos de arrastre basados en eventos pointer, se debe agregar la clase Tailwindtouch-nonepara bloquear el desplazamiento/zoom predeterminado del navegador. -
El store de arrastre de Zustand es una única instancia global: No crear stores de arrastre separados por funcionalidad. Todos deben compartir el archivo
src/presentation/shared/stores/drag-store.ts. -
Falta de
prefers-reduced-motion: En todos los Tiers, se debe deshabilitar la animación con@media (prefers-reduced-motion: reduce)o la variante Tailwindmotion-reduce:*. Si falta,/react-auditemitirá una advertencia. -
La animación es solo para presentación: No importar eventos pointer, view transitions o el store de arrastre de Zustand en las capas
domain/odata/. El código relacionado con animaciones debe estar únicamente ensrc/presentation/. -
Firefox no soporta el parámetro
types: La opcióntypes: [...]endocument.startViewTransition({ update, types: [...] })solo es compatible de forma estable en navegadores Chromium a partir de 2026. En el wrapperwithViewTransition, manejar el fallback con try/catch. -
Compatibilidad de navegadores para animaciones impulsadas por scroll (actualizado el 13/08/2026):
animation-timeline: scroll()/scroll-timelinetiene una cobertura global del 85.43% según Can I Use — Chrome/Edge 115+, Safari/iOS 26+, Firefox 156+. La afirmación de que Firefox lo tiene detrás de un flag está desactualizada. Sin embargo, según MDN,view()aún no es Baseline, por lo que se mantiene la política de envolver con@supports (animation-timeline: scroll())/@supports (animation-timeline: view())y usar estilos estáticos como fallback en caso de no soporte. -
Uso de
view-transition-name: match-element(Chrome 137+): En lugar de asignar nombres únicos manualmente a decenas de elementos, se puede usar el valor CSSview-transition-name: match-elementpara que el navegador empareje los elementos automáticamente. Sin embargo, a partir del segundo trimestre de 2026, esto es exclusivo de Chromium, por lo que se debe mantener una ruta de fallback. -
El movimiento no se demuestra con código estático — se requiere evidencia antes de declarar finalización (E2): El producto de esta habilidad solo se puede verificar mediante la reproducción. El hecho de que exista una clase
animate-*o que el código llame astartViewTransitiones evidencia R3 (estática), no evidencia de que la animación se haya reproducido realmente — keyframes no definidos,prefers-reduced-motionsiempre activo, clipping poroverflow: hiddendel padre, o la entrada al fallback de un navegador no compatible, todo termina silenciosamente en "no pasa nada". Justo antes de finalizar, completar la lista de verificación §4 dereact-kit/references/render-evidence-protocol.md. Al verificar con comparación de capturas, señalar dos momentos: antes y después — una sola imagen fija produce el mismo resultado para cualquier movimiento, por lo que no es un oráculo (§3 d). Si no se puede obtener evidencia, agregar[No verificado]y llenar cuatro campos (lo que bloquea · las soluciones intentadas · la razón fuera de control · el comando para re-verificar — según el protocolo §2) para reportar como finalización parcial.No introducir bibliotecas de animación para obtener evidencia. La política de bibliotecas (Advertencia #1) tiene prioridad sobre este protocolo y no se relaja por ninguna conveniencia de verificación.
-
La captura de referencia se toma antes de editar — Ejecutar el Paso 0 de §1 y el punto 1 del orden de comparación de §2 de
react-kit/references/render-evidence-protocol.mdantes de la primera edición, y dejar en la respuesta el resultado (repetición · tabla de convenciones · ruta de captura del estado actual y la marca de lo que debe cambiar). Después de editar, ya no se puede capturar el estado anterior a la edición. -
El Tier 2 se segmenta por tipo de actualización —
<ViewTransition>de React 19.3 es una API estable (añadido el 26/09/2026):<ViewTransition>importado desdereactse convierte en una API estable, no experimental, a partir de la versión 19.3. Se ejecuta cuando los hijos envueltos cambian de estilo, se montan o desmontan mediante actualizaciones marcadas como Transiciones (actualizaciones dentro destartTransition, Suspense reveal,useDeferredValue), y solo funciona en el DOM (fuente: https://react.dev/blog/2026/09/09/react-19-3 «We shared it as an experimental API last year, and in 19.3 it's stable and ready to use.»). Por lo tanto, para las actualizaciones de estado de React marcadas como Transiciones, usar<ViewTransition>, y para otras actualizaciones del DOM (actualizaciones síncronas no marcadas como Transiciones, manipulación del DOM fuera de React), usar el wrapperwithViewTransitionde §3.1. No eliminar el wrapper. El componente dentro del paquetereactno infringe el principio de cero bibliotecas (#1). Si la versión dereactdel proyecto es inferior a 19.3, usar solo el wrapper.
Proceso
1. Decisión automática de nivel
Si no hay flag --tier, analizar la solicitud del usuario y seleccionar el nivel más bajo.
| Palabra clave / Escenario | Nivel asignado |
|---|---|
| "fade in", "slide up", "scale", "pulse", "bounce", "hover effect", "opacity", "shimmer", "animación de entrada" | T1 |
| "transición de skeleton → carga completa", "hover de botón", "accordion", "open/close de modal", "cambio de estado" | T1 |
| "animación de scroll", "scroll-driven", "parallax", "barra de progreso de scroll", "basado en scroll" | T1 |
| "transición de página", "shared element", "grid to board", "transición de vista", "transición de ruta", "cambio de estructura DOM" | T2 |
| "scroll-triggered", "activar al cruzar el scroll", "iniciar animación en una posición específica" | T2 |
| "drag & drop", "lista ordenable", "sortable", "kanban", "arrastre", "soltar", "gesture", "línea de conexión con flecha" | T3 |
Si el límite entre T2/T3 es ambiguo, consultar al agente animation-architect-react.
Resumen de 3 niveles:
| Nivel | Herramienta | Escenario de aplicación | Dificultad |
|---|---|---|---|
| T1 | animate-* de Tailwind + @keyframes CSS + scroll-driven | Cambio de estado, hover, bucle simple, vinculado a scroll | Baja |
| T2 | API View Transitions (wrapper withViewTransition) · <ViewTransition> de React 19.3 (solo actualizaciones marcadas como Transición — Advertencia 15) | Transición de vista/ruta, shared element, FLIP | Media |
| T3 | Pointer Events + FSM + requestAnimationFrame | Drag & drop, gestos, líneas de conexión SVG | Alta |
2. Nivel 1 — Implementación con Tailwind + CSS
2.1 Utilidades integradas de Tailwind
| Categoría | Utilidad | Ejemplo |
|---|---|---|
| Transición | transition-transform, transition-colors, transition-opacity | hover:scale-105 transition-transform duration-200 |
| Duración | duration-75~duration-1000 | duration-300 |
| Easing | ease-linear, ease-in, ease-out, ease-in-out | ease-in-out |
| Animación (integrada) | animate-spin, animate-ping, animate-pulse, animate-bounce | Carga, notificación |
| Retardo | delay-75~delay-1000 | Efecto stagger |
2.2 Utilidades extendidas de tailwindcss-animate
Animaciones de entrada/salida proporcionadas por el plugin tailwindcss-animate:
animate-in/animate-outfade-in-*,fade-out-*slide-in-from-top/bottom/left/right-*,slide-out-to-*zoom-in-*,zoom-out-*
Combinar con el selector de atributo data-[state=...] para implementar animaciones basadas en estado:
// Ejemplo de abrir/cerrar un drawer
<div
data-state={open ? 'open' : 'closed'}
className="
data-[state=open]:animate-in data-[state=closed]:animate-out
data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0
data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left
duration-300
motion-reduce:transition-none motion-reduce:animate-none
"
>
Contenido del drawer
</div>
2.3 Declaración directa de CSS @keyframes
Las animaciones personalizadas que no cubren las utilidades de Tailwind se declaran en el bloque @theme de globals.css:
/* src/presentation/styles/globals.css */
@import "tailwindcss";
@theme {
--animate-wiggle: wiggle 1s ease-in-out infinite;
--animate-shimmer: shimmer 2s linear infinite;
@keyframes wiggle {
0%, 100% { transform: rotate(-3deg); }
50% { transform: rotate(3deg); }
}
@keyframes shimmer {
from { background-position: -200% 0; }
to { background-position: 200% 0; }
}
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
Uso: <div className="animate-wiggle">...</div>
2.4 Ejemplos de escenarios del Nivel 1
Entrada con fade-in:
<div className="animate-in fade-in-0 duration-500 motion-reduce:animate-none">
Entrada con fade al cargar la página
</div>
Botón con hover scale:
<button className="transition-transform duration-200 hover:scale-105 active:scale-95 motion-reduce:transition-none">
Botón
</button>
Entrada de lista con stagger:
{items.map((item, i) => (
<div
key={item.id}
className="animate-in slide-in-from-bottom-4 fade-in-0 motion-reduce:animate-none"
style={{ animationDelay: `${i * 50}ms`, animationFillMode: 'backwards' }}
>
{item.name}
</div>
))}
2.5 Animaciones impulsadas por scroll (CSS nativo)
Implementar animaciones vinculadas a la posición de scroll sin JavaScript usando CSS animation-timeline. A fecha del 13/08/2026, scroll() / scroll-timeline es compatible en Chrome/Edge 115+, Safari/iOS 26+, Firefox 156+ (cobertura global del 85.43% según Can I Use), mientras que view() aún no es Baseline según MDN. En ambos casos, envolver con @supports para un fallback elegante en navegadores no compatibles.
Barra de progreso de scroll:
/* globals.css */
@keyframes progress-grow {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}
<div
className="fixed top-0 left-0 h-1 w-full origin-left bg-primary motion-reduce:hidden"
style={{
animation: 'progress-grow linear',
animationTimeline: 'scroll(root block)',
}}
/>
Fade-in al entrar un elemento (view timeline):
@keyframes fade-slide-in {
from { opacity: 0; transform: translateY(2rem); }
to { opacity: 1; transform: translateY(0); }
}
@supports (animation-timeline: view()) {
.scroll-reveal {
animation: fade-slide-in linear both;
animation-timeline: view();
animation-range: entry 0% entry 100%;
}
}
<section className="scroll-reveal motion-reduce:opacity-100">
Contenido que aparece al hacer scroll
</section>
Nota: Las animaciones scroll-triggered (Chrome 145, 2026) son un concepto diferente que activa una animación basada en tiempo al cruzar un desplazamiento de scroll específico. No confundir con scroll-driven. Las scroll-triggered se clasifican como candidatas a Nivel 2.
3. Nivel 2 — Implementación de View Transitions API
Las View Transitions same-document son Baseline 2025 según MDN, con una cobertura global del 90.2% según Can I Use — Chrome/Edge 111+, Safari 18+, Firefox 144+ (actualizado el 13/08/2026). Proporcionan animación FLIP automática al cambiar el DOM dentro de una SPA.
Por otro lado, las transiciones cross-document / MPA basadas en @view-transition y algunas funciones detalladas aún tienen disponibilidad limitada. Al usarlas para transiciones de ruta, se debe establecer la ruta same-document como predeterminada y mantener la detección de características + fallback (§6, brecha de cobertura estándar n.º 8).
3.1 Creación del wrapper withViewTransition
Crear el archivo si no existe:
// src/presentation/shared/lib/view-transition.ts
export function withViewTransition(
updateDOM: () => void,
options?: { types?: string[] },
): void {
// Respetar prefers-reduced-motion
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
updateDOM()
return
}
// Feature detection (fallback para navegadores antiguos)
if (typeof document.startViewTransition !== 'function') {
updateDOM()
return
}
// Comprobar soporte del parámetro types (Firefox no lo soporta en implementaciones iniciales)
try {
if (options?.types && options.types.length > 0) {
document.startViewTransition({ update: updateDOM, types: options.types })
} else {
document.startViewTransition(updateDOM)
}
} catch {
updateDOM()
}
}
3.2 Elemento compartido con view-transition-name CSS
Identificar elementos como "el mismo" aunque cambie la estructura del DOM para una transición suave:
// Garantizar unicidad con id dinámico (prohibido nombres duplicados)
<div
className="rounded-lg border p-4"
style={{ viewTransitionName: `task-${task.id}` }}
>
{task.title}
</div>
3.3 Transición de vista grid ↔ board (escenario representativo)
// src/presentation/features/tasks/components/task-switcher.tsx
import { withViewTransition } from '@/presentation/shared/lib/view-transition'
import { useTaskStore } from '../store'
export function TaskViewSwitcher() {
const viewMode = useTaskStore((s) => s.viewMode)
const setViewMode = useTaskStore((s) => s.setViewMode)
return (
<button
onClick={() => {
withViewTransition(() => {
setViewMode(viewMode === 'grid' ? 'board' : 'grid')
})
}}
>
{viewMode === 'grid' ? 'A vista board' : 'A vista grid'}
</button>
)
}
export function TaskList({ tasks }: { tasks: Task[] }) {
const viewMode = useTaskStore((s) => s.viewMode)
return (
<div className={viewMode === 'grid' ? 'grid grid-cols-3 gap-4' : 'flex gap-4 overflow-x-auto'}>
{tasks.map((task) => (
<div
key={task.id}
className="rounded-lg border p-4"
style={{ viewTransitionName: `task-${task.id}` }}
>
{task.title}
</div>
))}
</div>
)
}
3.4 Ajuste fino de la transición CSS
/* globals.css — duración + easing de la transición */
::view-transition-group(*) {
animation-duration: 0.4s;
animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
}
/* Ajuste individual para elementos específicos */
::view-transition-group(hero-image) {
animation-duration: 0.6s;
}
/* Transición inmediata con reduced-motion */
@media (prefers-reduced-motion: reduce) {
::view-transition-group(*) {
animation-duration: 0.01ms;
}
}
4. Nivel 3 — Implementación de primitivas personalizadas con Pointer
Implementar directamente la API Pointer Events + máquina de estados finitos (FSM) sin bibliotecas.
4.1 Creación del hook useDrag
// src/presentation/shared/hooks/use-drag.ts
import { useCallback, useEffect, useReducer, useRef } from 'react'
// FSM de 3 estados: idle / dragging / dropping
type DragState =
| { kind: 'idle' }
| { kind: 'dragging'; startX: number; startY: number; offsetX: number; offsetY: number; pointerId: number }
| { kind: 'dropping'; offsetX: number; offsetY: number }
type DragAction =
| { type: 'start'; x: number; y: number; pointerId: number }
| { type: 'move'; x: number; y: number }
| { type: 'drop' }
| { type: 'resolved' }
| { type: 'cancel' }
function dragReducer(state: DragState, action: DragAction): DragState {
switch (action.type) {
case 'start':
return { kind: 'dragging', startX: action.x, startY: action.y, offsetX: 0, offsetY: 0, pointerId: action.pointerId }
case 'move':
if (state.kind !== 'dragging') return state
return { ...state, offsetX: action.x - state.startX, offsetY: action.y - state.startY }
case 'drop':
if (state.kind !== 'dragging') return state
return { kind: 'dropping', offsetX: state.offsetX, offsetY: state.offsetY }
case 'resolved':
case 'cancel':
return { kind: 'idle' }
}
}
type DragHandlers = {
onPointerDown: (e: React.PointerEvent) => void
onPointerMove: (e: React.PointerEvent) => void
onPointerUp: (e: React.PointerEvent) => void
onPointerCancel: (e: React.PointerEvent) => void
}
export function useDrag(): {
state: DragState
handlers: DragHandlers
ref: React.RefObject<HTMLElement | null>
resolve: () => void
} {
const [state, dispatch] = useReducer(dragReducer, { kind: 'idle' })
const ref = useRef<HTMLElement | null>(null)
const onPointerDown = useCallback((e: React.PointerEvent) => {
if (state.kind !== 'idle') return
// Capturar el puntero para seguimiento fiable
// (Se puede usar e.currentTarget.setPointerCapture(e.pointerId))
dispatch({ type: 'start', x: e.clientX, y: e.clientY, pointerId: e.pointerId })
}, [state.kind])
const onPointerMove = useCallback((e: React.PointerEvent) => {
dispatch({ type: 'move', x: e.clientX, y: e.clientY })
}, [])
const onPointerUp = useCallback((e: React.PointerEvent) => {
dispatch({ type: 'drop' })
}, [])
const onPointerCancel = useCallback((e: React.PointerEvent) => {
// Restablecer FSM a idle (Advertencia #5)
dispatch({ type: 'cancel' })
}, [])
const resolve = useCallback(() => {
dispatch({ type: 'resolved' })
}, [])
return {
state,
handlers: { onPointerDown, onPointerMove, onPointerUp, onPointerCancel },
ref,
resolve,
}
}