Cómo construí la animación del logo de Quo.js con un Engine fuera de React, suscripciones atómicas dentro de React, y casi cero código repetitivo.
TL;DR: Convertí un PNG estático del logo de Quo.js en cientos de círculos SVG animados que se ensamblan, se dispersan alrededor del mouse, y regresan suavemente a casa — todo en React 19 + TypeScript usando @quojs/core@0.2.0 y @quojs/react@0.2.0. El truco es ejecutar un pequeño Engine completamente fuera de React, transmitir actualizaciones de estado en lotes a un store de Quo.js, y luego renderizar con suscripciones atómicas en React para que la UI solo se re-renderice cuando las coordenadas de un círculo específico realmente cambien.
Tech: React 19, TypeScript, Vite, SVG
State: @quojs/core y @quojs/react
Lo que NO se usa: three.js, WebGL, WebGPU. Esto es puro DOM/SVG + requestAnimationFrame.
Lo que verás (GIFs)
Si visitas el sitio web de Quo.js, verás esto:
-
Intro: las partículas convergen en la marca del logo "Quo.js".
Y si juegas con el cursor:
-
.
Interactividad: los círculos orbitan alejándose del cursor y luego regresan a su punto de partida.
Dev.to podría comprimir los archivos GIF, si ves animaciones lentas, considera descargar los archivos GIF (intro e interacción) o visitar https://quojs.dev/?lang=es para un fluido 60fps.
La idea en un diagrama
Mantenemos React ligero al empujar todo el trabajo de animación a un nano-Engine simple y plano de TypeScript. El Engine posee el bucle de frames, quadtree y matemáticas de movimiento. Envía actualizaciones en lotes a un Store de Quo.js. React consume solo el pequeño fragmento que necesita por círculo mediante selectores atómicos.
- Engine (fuera de React): bucle requestAnimationFrame, clamp de dt, suavizado de FPS, quadtree, física de círculos.
- Store Quo.js: reducers puramente funcionales, efectos para temporizaciones pequeñas y eventos de ciclo de vida.
-
React: solo renderiza nodos de círculo vía
useSliceProp('logo', 'd.circle_0')etc.
¿Por qué este diseño? (Rendimiento y simplicidad)
- React permanece inactivo a menos que sea necesario. Cada componente
<Circle>se suscribe a su propia ruta x,y. Sin re-renders globales. Sin prop drilling. - El Engine es agnóstico del framework. No sabe sobre React. Solo despacha acciones (batchUpdate) al store.
- Menos código repetitivo que Redux/RTK. Quo.js usa canales + eventos, efectos sin thunks/sagas, y selectores atómicos para suscribirse a rutas exactas con notación de puntos.
- Actualizaciones en lotes por frame + un reducer compacto mantienen las escrituras de estado mínimas.
- rAF + clamp de dt + buffer circular de FPS dan movimiento suave sin tartamudeos.
Paso 1 – Modelar el estado y eventos de canal
Mantenemos el estado logo para la animación.
export type Circle = { id: string; x: number; y: number; r?: number };
export type GroupedCircle = { group: "d" | "u" | "x" } & Circle;
export type LogoState = {
enabled: boolean;
fps: number;
itemCount: { d: number; u: number; x: number };
size: { height: number; width: number };
d: Record<string, Circle>;
u: Record<string, Circle>;
x: Record<string, Circle>;
intro: { remaining: number; total?: number; done: boolean };
};
export type LogoAM = {
start: {};
stop: {};
fps: { fps: number };
size: { height: number; width: number };
count: { d: number; u: number; x: number };
update: GroupedCircle;
batchUpdate: { changes: GroupedCircle[] };
introProgress: { remaining: number; total: number };
introComplete: {};
};
Patrón de dispatch:
store.dispatch("logo", "batchUpdate", { changes: [...] });
Paso 2 – El reducer: inmutable, rápido
import type { ActionPair, ReducerSpec } from "@quojs/core";
import type { AppAM, LogoAM, LogoState, Circle } from "../types";
export const LOGO_INITIAL_STATE: LogoState = {
enabled: true,
d: Object.create(null),
u: Object.create(null),
x: Object.create(null),
fps: 0,
itemCount: { d: 0, u: 0, x: 0 },
size: { height: 0, width: 0 },
intro: {
remaining: 0,
total: 0,
done: false,
},
};
const LOGO_ACTIONS = [
["logo", "update"],
["logo", "stop"],
["logo", "fps"],
["logo", "size"],
["logo", "count"],
["logo", "batchUpdate"],
["logo", "introProgress"],
["logo", "introComplete"],
["logo", "start"],
] as const satisfies readonly ActionPair<AppAM>[];
// azúcar para inferencia de tipos
type GroupKey = keyof Pick<LogoState, "d" | "u" | "x">;
function upsertItem(state: LogoState, group: GroupKey, next: Circle): LogoState {
const groupMap = state[group];
const prev = groupMap[next.id];
// insertar
if (!prev) {
const nextGroup = { ...groupMap, [next.id]: next };
return { ...state, [group]: nextGroup };
}
// actualizar solo si algo realmente cambió
if (
prev.x === next.x &&
prev.y === next.y
) {
return state; // no-op
}
const nextGroup = { ...groupMap, [next.id]: { ...prev, ...next } };
return { ...state, [group]: nextGroup };
}
export const logoReducer: ReducerSpec<LogoState, AppAM> = {
actions: [
...LOGO_ACTIONS
],
state: LOGO_INITIAL_STATE,
reducer: (state, action) => {
if (action.channel !== "logo") return state;
if (!state.enabled && action.event !== "start") return state;
switch (action.event) {
case "update": {
const { group, id, x, y } = action.payload;
const next: Circle = { id, x, y };
return upsertItem(state, group as GroupKey, next);
}
case "start": {
if (state.enabled) return state;
return {
...state,
enabled: true,
};
}
case "stop": {
if (!state.enabled) return state;
return {
...state,
enabled: false
};
}
case "fps": {
const { fps } = action.payload as LogoAM["fps"];
if (state.fps === fps) return state;
return {
...state,
fps,
};
}
case "count": {
const next = action.payload as LogoAM["count"];
const prev = state.itemCount;
if (prev.d === next.d && prev.u === next.u && prev.x === next.x) return state;
return { ...state, itemCount: next };
}
case "size": {
const { height, width } = action.payload as LogoAM["size"];
const prev = state.size;
if (prev.height === height && prev.width === width) return state;
return {
...state, size: {
height,
width,
}
};
}
case "batchUpdate": {
if (!action.payload.changes.length) return state;
const { changes } = action.payload;
let wroteAny = false;
for (const c of changes) {
let prev = state[c.group][c.id];
if (!prev) {
prev = {
...state[c.group][c.id],
...c,
};
state = {
...state,
[c.group]: {
...state[c.group],
[c.id]: prev,
}
};
wroteAny = true;
continue;
}
const nx = c.x ?? prev.x;
const ny = c.y ?? prev.y;
if (nx !== prev.x || ny !== prev.y) {
state = {
...state,
[c.group]: {
...state[c.group],
[c.id]: { ...prev, x: nx, y: ny },
}
};
wroteAny = true;
}
}
return wroteAny ? { ...state } : state;
}
case "introProgress": {
const { remaining, total } = action.payload;
return {
...state,
intro: {
...state.intro,
remaining,
total,
}
};
}
case "introComplete": {
return {
...state,
intro: {
...(state.intro ?? {}),
remaining: 0,
done: true
}
};
}
default:
return state;
}
},
};
- Evita escrituras si x,y no han cambiado.
- Actualiza muchos cambios en lote por frame.
- Solo toca las claves modificadas.
Paso 3 – Crear el store
export const store = createStore({
name: "Quo.js",
reducer: { logo: logoReducer },
effects: [],
});
Paso 4 – El Engine (fuera de React)
- Ejecuta bucle rAF.
- Calcula dt y FPS.
- Despacha logo/batchUpdate.
- Se suscribe a efectos del store para start/stop.
- Nunca importa React.
if (dt > 0) this.simulation.loop(this._dt, now);
if (this._running && gen === this._rafGen) {
this._handle = requestAnimationFrame((t) => this._tick(t, gen));
}
Cada Circle emite actualizaciones { group, id, x, y } cuando se mueve.
Paso 5 – Conectar Engine y Store en React
useEffect(() => {
const engine = new Engine({ targetFPS: 60, autoStart: false }, store);
const setup = async () => {
const image = await loadImagePixels(quoLogo);
const { specs, width, height, groupCounts } = extractCircleSpecsFromImage(
image,
{ spacing: 3, initialR: 0.5, maxCircles: 1500 }
);
store.dispatch("logo", "size", { height, width });
store.dispatch("logo", "count", groupCounts);
const sim = new Simulation(engine, { items: specs, name: "Quo Packing" });
engine.attach(sim);
engine.init();
engine.start();
};
setup();
return () => engine.teardown();
}, [store]);
Espera, manu—¿cómo convertimos un PNG en un enjambre de círculos?
Bajo el capó, extractCircleSpecsFromImage carga el PNG del logo en un canvas fuera de pantalla, muestrea el alpha de píxeles en una cuadrícula configurable (predeterminado: espaciado de 8px), y emite una especificación de círculo donde alpha > 50.
const threshold = 0.2; // 0–1, ajusta para densidad
const spacing = 8;
for (let y = 0; y < h; y += spacing) {
for (let x = 0; x < w; x += spacing) {
const alpha = ctx.getImageData(x, y, 1, 1).data[3] / 255;
if (alpha > threshold) {
circles.push({ x, y, r: spacing * 0.45 });
}
}
}
¿Quieres un enjambre más denso? Reduce el espaciado. ¿Quieres iluminación ambiental? Mapea alpha → radio. Es solo data—hackéalo a tu gusto.
Paso 6 – Pegamento de React con @quojs/react
export const { useStore, useDispatch, useSliceProp } =
createQuoHooks(AppStoreContext);
Ahora cada <Circle> se suscribe atómicamente:
export const Circle = ({ id, group }) => {
const path = `${group}.${id}`;
const { x, y } = useSliceProp({
reducer: "logo",
property: path,
}) ?? { x: 0, y: 0 };
return <circle className={`group-${group}`} cx={`${x}px`} cy={`${y}px`} />;
};
Solo ese círculo se re-renderiza cuando sus propias coordenadas cambian.
Resumen del flujo de datos
FAQ / Notas
- ¿Por qué no RTK? Necesitaba modelo de canal/evento, efectos asíncronos, suscripciones atómicas.
- ¿Por qué no three.js/WebGL? Exagerado para partículas 2D; SVG + Quo.js es ligero.
- ¿Completado de intro? La simulación rastrea círculos restantes → despacha introProgress → introComplete.
- ¿FPS? Promediado vía buffer circular, despachado escasamente.
¿Por qué despachos escasos, no spam por frame?
Inundar React con 60 actualizaciones de estado/seg es un big-bang de renderizado. En su lugar, el Engine usa un buffer circular para rastrear tiempos de frame, calculando FPS solo cuando el buffer se completa (~cada 30 frames).
if (this.frameCount % 30 === 0) {
const fps = 30 / (deltaSum / 1000);
this.store.dispatch(setFps(fps));
}
¿Resultado? Una actualización atómica cada ~500ms, cero jank, y Redux Devtools permanece sano. —sí, lo leíste bien: Quo.js soporta Redux Devtools—. La telemetría de rendimiento debe servir a la animación—no matarla de hambre.
Reproducir localmente
pnpm add @quojs/core@0.2.0 @quojs/react@0.2.0
- Crear store (ver Paso 3).
- Envolver
<AppStoreContext.Provider value={store}>. - Montar Engine (Paso 5).
- Renderizar SVG Circles (Paso 6).
Docs para Quo.js y amigos:
Patrones pequeños pero impactantes
- Escrituras en lotes por frame.
- Guardia de no-op en valores idénticos.
- Suscripciones a rutas exactas.
- Guardia de tokens de generación rAF.
- Despacho escaso de telemetría.
¡Pruébalo!
- ⭐ Da una estrella a Quo.js en GitHub
- 🧪 Prueba @quojs/core
- 🧪 Prueba @quojs/react
- 🐞 Reporta issues / ideas
- 🧩 Contribuye ejemplos
Reflexiones finales
Esta animación muestra cómo React puede ser un renderizador, no un bucle de juego. Manejando cambios de estado precisos y granulares, obtienes alto FPS, poca agitación de React, y lógica mantenible.
Una nota rápida sobre los nombres de los hooks para useSliceProp y useSliceProps: Estos probablemente cambiarán a algo más significativo como useAtomicProp y useAtomicProps antes del próximo lanzamiento (en una semana), así que no los uses todavía en producción. Quo.js todavía está en pruebas beta.
Te deseo buen código, manu.
Licencia: MPL-2.0 — comparte con el mundo.


Top comments (0)