Animación de construcción por enjambre de agentes
Una capa de lienzo fija dibuja un pequeño equipo de cursores que "construyen" cada sección de una página cuando aparece en la ventana. El contenido es HTML real, renderizado en el servidor todo el tiempo. El motor solo oculta elementos durante unos segundos y reproduce una breve coreografía para cada uno.
Ejemplo en vivo: https://www.lamanhtruong.com. Su código fuente está anclado en
portfolio-anh@a5d1651.
assets/swarm/ contiene el mismo motor, limpiado para reutilización.
Cuándo usarlo y cuándo no
Úsalo cuando la página en sí sea la propuesta: un portafolio, una página de lanzamiento o historia de producto, un caso de estudio, una página "sobre nuestra IA". La gente visita una vez, hace scroll de arriba a abajo, y la construcción les dice algo ("esta persona lanza con agentes").
Evítalo para cualquier cosa que la gente use a diario: paneles de control, documentación, configuraciones, pago, lectura extensa. La construcción cuesta unos segundos por sección, y un usuario que regresa no tiene razón para verla dos veces. Por eso el motor recuerda una construcción completada por pestaña (sessionStorage) y nunca la reproduce al recargar.
Lo que cuesta:
- Aproximadamente 5000 líneas de TypeScript. La capa de construcción se carga con
next/dynamicdespués de la hidratación, por lo que la primera pintura nunca la espera; las piezas de desplazamiento suave y paralaje (Lenis, GSAP, ScrollTrigger) se cargan con la página. Una aplicación nueva de Next.js 15 con las cuatro habilidades living-ui resultó en aproximadamente 67 kB de JS de ruta (170 kB en la primera carga). - Una pequeña puntuación de cambio de diseño mientras se llena el registro en vivo (aproximadamente 0.03 en una página de prueba, muy por debajo de la línea "buena" de 0.1): las nuevas líneas de registro se mueven dentro de su cuadro de altura fija. El resto de la página nunca se desplaza.
- Un lienzo fijo adicional y un bucle
gsap.ticker. El bucle se detiene cuando no hay nada en movimiento. - GSAP 3.13+ (gratuito, incluido ScrollTrigger),
@gsap/react, Lenis.
Cómo funciona en un párrafo
Un pequeño script en línea en <head> establece html[data-build="on"] antes de la
primera pintura, y CSS oculta todos los [data-build-item] no construidos con
opacity: 0. La opacidad en lugar de display o visibilidad significa que no hay
cambio de diseño y nada se elimina del árbol de accesibilidad. Después de la
hidratación, el motor escanea la página en un grafo de tareas por
[data-build-section], arma un ScrollTrigger para cada sección, y cuando una
entra en la ventana, ejecuta planificar → construir → revisar:
- Planificar: un planificador dibuja guías y distribuye tareas.
- Construir: los trabajadores reclaman tareas por distancia, rol y la ruta crítica, y cada tarea reproduce una línea de tiempo de GSAP.
- Revisar: un revisor delinea la sección, marca una lista de verificación y la sella.
Los elementos y secciones terminados obtienen [data-built]. Si el motor nunca
se inicia, el script en la cabecera elimina la bandera después de 3 s para que
nada permanezca oculto.
Arquitectura, flujo de datos y cómo extenderlo: references/architecture.md.
Instalación (Next.js App Router)
-
Dependencias. Probado con Next.js 15.1 + React 18.3 (el ejemplo trabajado) y Next.js 15.5 + React 19.1 + Tailwind 4 (un
create-next-appnuevo), TypeScript 5.8–5.9,[email protected]–3.15,@gsap/[email protected],[email protected]:npm i gsap@^3.13 @gsap/react@^2.1 lenis@^1.3 -
Copia el motor. Copia
assets/swarm/asrc/components/swarm/. Las importaciones dentro de él son relativas, por lo que cualquier carpeta funciona. -
Añade el script de bandera al
<head>del diseño raíz. Establece un atributo en<html>antes de que React hidrate, así que añadesuppressHydrationWarninga<html>:// app/layout.tsx import { BuildFlagScript } from "@/components/swarm/build-flag-script"; export default function RootLayout({ children }: { children: React.ReactNode }) { return ( <html lang="en" suppressHydrationWarning> <head> <BuildFlagScript /> </head> <body>{children}</body> </html> ); } -
Monta el motor una vez, en las páginas que lo usen, después del contenido:
// app/page.tsx (un componente de servidor; permanece estático) import "@/components/swarm/swarm.css"; import { SwarmRoot } from "@/components/swarm/swarm-root"; export default function Page() { return ( <> <main>{/* secciones marcadas con data-build-* */}</main> <SwarmRoot /> </> ); }SwarmRoottambién monta el desplazamiento suave de Lenis impulsado porgsap.ticker. Si la aplicación ya ejecuta Lenis, elimina<SmoothScroll />deswarm-root.tsx.build-layer.tsxlee la instancia a través deuseLenis(), que necesita una instancia raíz deReactLenisen alguna parte de la página (<ReactLenis root />, como montasmooth-scroll.tsx). No tiene que ser un padre. -
Mantén la ruta estática. El motor solo decora HTML que ya existe. No llames a
cookies(),headers()oauth()en el árbol de diseño de la página solo para leer una sesión. Obtén eso en el cliente en su lugar, y la página se puede prerenderizar y servir desde el CDN. En páginas que no montenSwarmRoot, el script de bandera es inofensivo: ningún elemento llevadata-build-item, y el mecanismo de seguridad elimina la bandera. -
Personaliza el tema. Establece variables CSS (
--swarm-font-mono,--swarm-accent,--swarm-text,--swarm-muted,--swarm-dim,--swarm-line,--swarm-neutral-text|fill|stroke), luego editaCOLORSyROLE_COLORenpalette.ts. El lienzo no puede leer variables CSS de manera eficiente en cada fotograma, por lo que su paleta reside en TS.
Marca la página
Todo se controla mediante atributos de datos en el marcado renderizado en el servidor. La
referencia completa, con valores predeterminados y comportamiento en teléfonos, está en
references/dom-contract.md. Recetas de copiar y pegar para un héroe, cuadrícula de tarjetas, lista,
imagen o video, tira de métricas y final están en references/recipes.md.
| Atributo | En | Significado |
|---|---|---|
data-build-section="nombre" | envoltorio de sección | Una unidad de construcción con su propio plan, equipo y revisión |
data-build-start="load" | sección | Construir justo después del inicio en lugar de al hacer scroll, con el presupuesto de héroe más grande |
data-build-item + data-build="frame|type|reveal|image|dup" | elemento | Una tarea de ese tipo |
data-build-list | contenedor de lista | Su primer elemento es el maestro (frame); los hermanos dup se copian de él |
data-build-grid | contenedor o la sección | Un agente de maquetación dibuja la cuadrícula y encuadra cada celda primero |
data-build-font / -weight / -scale | elementos type | Borrador mono y luego cambio de fuente; barrido de peso; empezar pequeño, luego redimensionar y mover |
data-build-color="text|fill|stroke" | cualquier elemento | La rueda de colores elige el color real después de que aparece el elemento |
data-build-tune | frame | Abre el inspector de radio y relleno |
data-build-from="left|right|top|bottom" | image | Borde desde el que se arrastra la imagen |
data-build-keep / ="mobile" | cualquier elemento | Nunca oculto (solo en teléfonos con mobile), aún anotado |
data-build-label, -order, -role | cualquier elemento | Texto de etiqueta, sugerencia de orden, qué rol lo toma |
data-swarm-spawn="role" | <g> SVG en el héroe | Nodos de malla de los que el equipo sale al inicio (≥1024 px) |
data-swarm-log | contenedor | Las líneas de respaldo renderizadas en el servidor se intercambian por un registro en vivo |
data-build-finale + data-deploy-target + data-deploy-text | última sección, su CTA | El desplegador "lanza" la página después de la última revisión |
?build=off / ?build=force | URL | Deshabilitar para esta vista / repetir incluso si esta pestaña ya lo vio |
El motor establece: html[data-build="on"] mientras está activo, [data-built] en
elementos y secciones terminados, [data-lit] en nodos de malla durante el inicio, y
[data-deployed] en el objetivo del final.
Reglas que mantienen su funcionamiento:
- Tamaño de la sección. Aproximadamente una ventana de elementos por sección. Divide páginas largas: una sección por trabajo en una línea de tiempo, una por tarjeta en una cuadrícula grande, una sección separada para un encabezado de sección. Las secciones pequeñas terminan dentro de su presupuesto y se ven deliberadas; las enormes recurren a la velocidad de recuperación.
- Anidamiento. Un elemento anidado dentro de otro elemento espera a su padre. Marca
la tarjeta como
framey su título, texto y chips dentro de ella. - Tipo. Usa
typesolo para texto corto (menos de aproximadamente 120 caracteres). El texto más largo recurre automáticamente areveal. Escribir lee los cuadros de glifos una vez, por lo que el texto gestionado por React que cambia después de la hidratación (contadores, vistas ) debe ser unframe, notype. - LCP. Mantén el encabezado principal visible en teléfonos con
data-build-keep="mobile". En teléfonos, el encabezado del héroe es el Largest Contentful Paint, y ocultarlo cuesta Core Web Vitals. El escritorio aún obtiene la construcción completa de cambio de fuente. - Registro en vivo. El contenedor
data-swarm-logdebe renderizarse en el servidor con líneas de respaldo reales y una altura fija (.swarm-log). El motor las intercambia por líneas en vivo y las devuelve al omitir o desmontar.
Héroe mínimo:
<section data-build-section="hero" data-build-start="load">
<p data-build-item data-build="type">tu línea de cejas</p>
<h1
data-build-item data-build="type" data-build-font data-build-weight
data-build-keep="mobile" data-build-order="-1" data-build-role="coder"
>
Tu Nombre <span className="serif">(Acento)</span>
</h1>
<div data-build-item data-build="image" data-build-from="right" className="avatar">
<img src="/me.jpg" alt="Retrato" />
</div>
{/* next/image también funciona (renderiza un <img> interno que el motor lee). */}
<div data-build-list className="buttons">
<a data-build-item data-build="frame" href="#">GitHub</a>
<a data-build-item data-build="dup" href="#">LinkedIn</a>
<a data-build-item data-build="dup" data-build-color="fill" href="#">Currículum</a>
</div>
</section>
Reglas de suavidad
Estas surgieron al observar construcciones reales. Cada una solucionó un problema visible.
- Nunca forzar finalización en una fecha límite. Una versión temprana saltaba cada línea de
tiempo en ejecución a 5× de velocidad y colocaba los elementos no reclamados en la fecha límite
de cada sección. La gente lo veía como un tirón al final de cada sección.
Los presupuestos ahora son objetivos de planificación.
estimate()escala las duraciones de las tareas para que el plan encaje.- Cuando una sección se retrasa,
pace()aumenta un factor de recuperación suavemente, hasta un máximo de 1.7× durante 0.7 s. - Cada interpolación permanece al menos 120 ms incluso a máxima recuperación, y los pasos de escritura permanecen al menos 16 ms. Nada aparece de repente.
- Si lo necesitas más rápido, haz los cursores más rápidos (
speedenpalette.ts). No acortes las animaciones.
- Animar solo opacidad, clip-path, filter y transform. Sin escrituras de diseño. Los borradores de cambio de fuente son clones en una capa fuera del flujo, y la tarea de escala elimina su transformación en línea al final.
- Un reloj. La simulación y el dibujo del lienzo se ejecutan en un único callback
de
gsap.ticker. El estado de React solo cambia cuando la construcción comienza o se detiene, nunca por fotograma. El ticker se detiene cuando no hay agente en pantalla. - Medir con antelación. Las secciones hasta aproximadamente dos ventanas debajo del pliegue se
miden en fragmentos de
requestIdleCallback. Una sección que comienza nunca causa una gran ráfaga de lectura de diseño en el fotograma de desplazamiento. - Respetar al lector.
- Enfocar cualquier cosa dentro de una sección no construida finaliza esa sección de inmediato, para que los usuarios de teclado nunca esperen.
- Un cambio de tamaño durante la construcción finaliza las secciones en ejecución, porque sus rectángulos medidos están desactualizados.
- Una pestaña oculta pausa todo.
- El HUD siempre ofrece Omitir, Repetir e Inspeccionar.
- Los teléfonos obtienen menos. Cada perfil tiene su propio presupuesto, equipo y detalle
(
detectProfile()enpalette.ts).- Teléfonos: 8 agentes, aproximadamente 2.2 s por sección, un cambio de fuente por página, una selección de color por sección, un barrido de duplicados por lista, sin paneles de inspección, sin tarea de escala, sin desplegador.
- Tabletas: 11 agentes.
- Escritorio: 15 agentes, aproximadamente 3.9 s por sección, 5.25 s para el héroe.
Perillas para "más rápido", "más tranquilo" y "más agentes": references/tuning.md.
Movimiento reducido
El motor se reproduce para cada visitante, incluidas las personas cuyo sistema operativo solicita movimiento reducido. El propietario del sitio decidió que la construcción es el punto de la página, y el HUD siempre ofrece Omitir, por lo que esta es una elección deliberada, no un valor predeterminado para copiar ciegamente. Para respetar la configuración en su lugar, realiza estos tres cambios:
// build-flag-script.tsx: dentro de CODE, antes de d.setAttribute('data-build','on')
if (matchMedia('(prefers-reduced-motion: reduce)').matches) return;
// motion.ts
export function offByUrl(): boolean {
return OFF_RE.test(window.location.search) ||
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
// smooth-scroll.tsx
respectReducedMotion: true,
Prueba ambas formas: algunos navegadores (y algunas configuraciones de automatización) informan
reduce incluso cuando nadie lo eligió, que es una de las razones por las que el valor predeterminado aquí es
"activado".
Trabajar con otros efectos
-
Puerta en
[data-built]. El motor lee rectángulos de elementos y alterna opacidad y clip-path durante la construcción. Tilt, glitch, scramble, paralaje en contenido, o cualquier otra cosa que transforme un elemento corromperá esas mediciones si se ejecuta temprano. Inicia tales efectos solo cuando el elemento y su sección llevan[data-built], o cuandohtml[data-build]no es"on". Un ayudante listo:export function isBuilt(el: Element): boolean { if (document.documentElement.getAttribute("data-build") !== "on") return true; const item = el.closest("[data-build-item]"); if (item && !item.hasAttribute("data-built")) return false; const section = el.closest("[data-build-section]"); return !section || section.hasAttribute("data-built"); } -
Videos. Un elemento
imageque envuelve un<video poster>se arrastra usando su fotograma de póster como miniatura. Dale al envoltorio unadata-build-labelcomoclip.mp4, porque no hay<img>para leer un nombre de archivo. -
Paralaje decorativo está bien en cualquier lugar donde mueva solo decoración. Nunca lo pongas en algo que el motor mida.
Hazlo tuyo
El motor aquí es una implementación original, y el mismo estándar se aplica a cualquier cosa que construyas a partir de él. Si un sitio te inspiró, escribe qué hace reconocible a ese sitio y aléjate exactamente de esas cosas. Tomar prestado …
… [truncado para traducción]
