Como construí a animação do logo do Quo.js com um Engine fora do React, assinaturas atômicas dentro do React, e quase zero boilerplate.
TL;DR: Transformei um PNG estático do logo do Quo.js em centenas de círculos SVG animados que se montam, se dispersam ao redor do mouse, e deslizam de volta para casa — tudo em React 19 + TypeScript usando @quojs/core e @quojs/react. O truque é rodar um pequeno Engine completamente fora do React, transmitir atualizações de estado em lotes para uma store do Quo.js, e então renderizar com assinaturas atômicas no React para que a UI só re-renderize quando as coordenadas de um círculo específico realmente mudem.
Tech: React 19, TypeScript, Vite, SVG
State: @quojs/core e @quojs/react
O que NÃO é usado: three.js, WebGL, WebGPU. Isso é puro DOM/SVG + requestAnimationFrame.
O que você verá (GIFs)
Se você visitar o site do Quo.js, verá isto:
Intro: partículas convergem para a marca do logo "Quo.js".
E se você brincar com o cursor:
Interatividade: círculos orbitam para longe do cursor, então relaxam de volta para "casa".
Dev.to pode comprimir os arquivos GIF, se você vir animações lentas, considere baixar os arquivos GIF (intro e interação) ou visitar Quo.js site para 60fps suaves.
A ideia em um diagrama
Mantemos o React leve empurrando todo o trabalho de animação para um nano-Engine simples e plano de TypeScript. O Engine possui o loop de frames, quadtree e matemática de movimento. Ele envia atualizações em lotes para uma Store do Quo.js. O React consome apenas o pequeno pedaço que precisa por círculo via seletores atômicos.
- Engine (fora do React): loop requestAnimationFrame, clamp de dt, suavização de FPS, quadtree, física de círculos.
- Store Quo.js: reducers puramente funcionais, efeitos para pequenos temporizadores e eventos de ciclo de vida.
-
React: apenas renderiza nós de círculo via
useSliceProp('logo', 'd.circle_0')etc.
Por que esse design? (Performance e simplicidade)
- React fica ocioso a menos que seja necessário. Cada componente
<Circle>se inscreve em seu próprio caminho x,y. Sem re-renders globais. Sem prop drilling. - Engine é agnóstico de framework. Ele não sabe sobre React. Apenas despacha ações (batchUpdate) para a store.
- Menos boilerplate que Redux/RTK. Quo.js usa canais + eventos, efeitos sem thunks/sagas, e seletores atômicos para se inscrever em caminhos exatos com notação de pontos.
- Atualizações em lotes por frame + um reducer compacto mantêm as escritas de estado mínimas.
- rAF + clamp de dt + buffer circular de FPS dão movimento suave sem engasgos.
Passo 1 – Modelar o estado e eventos de canal
Mantemos o estado logo para a animação.
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: {};
};
Padrão de dispatch:
store.dispatch("logo", "batchUpdate", { changes: [...] });
Passo 2 – O reducer: imutável, 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>[];
// açúcar para inferência 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];
// inserir
if (!prev) {
const nextGroup = { ...groupMap, [next.id]: next };
return { ...state, [group]: nextGroup };
}
// atualizar apenas se algo realmente mudou
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 escritas se x,y não mudaram.
- Atualiza muitas mudanças em lote por frame.
- Apenas toca as chaves modificadas.
Passo 3 – Criar a store
export const store = createStore({
name: "Quo.js",
reducer: { logo: logoReducer },
effects: [],
});
Passo 4 – O Engine (fora do React)
- Roda loop rAF.
- Calcula dt e FPS.
- Despacha logo/batchUpdate.
- Se inscreve em efeitos da 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 atualizações { group, id, x, y } quando se move.
Passo 5 – Conectar Engine e Store no 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 aí, manu—como transformamos um PNG em um enxame de círculos?
Por baixo dos panos, extractCircleSpecsFromImage carrega o PNG do logo em um canvas fora da tela, amostra o alpha de pixels em uma grade configurável (padrão: espaçamento de 8px), e emite uma especificação de círculo onde alpha > 50.
const threshold = 0.2; // 0–1, ajuste para densidade
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 });
}
}
}
Quer um enxame mais denso? Diminua o espaçamento. Quer iluminação ambiente? Mapeie alpha → raio. É apenas dados—hackeie à vontade.
Passo 6 – Cola do React com @quojs/react
export const { useStore, useDispatch, useSliceProp } =
createQuoHooks(AppStoreContext);
Agora cada <Circle> se inscreve atomicamente:
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`} />;
};
Apenas esse círculo re-renderiza quando suas próprias coordenadas mudam.
Resumo do fluxo de dados
FAQ / Notas
- Por que não RTK? Precisava do modelo de canal/evento, efeitos assíncronos, assinaturas atômicas.
- Por que não three.js/WebGL? Exagero para partículas 2D; SVG + Quo.js é leve.
- Conclusão da intro? A simulação rastreia círculos restantes → despacha introProgress → introComplete.
- FPS? Média via buffer circular, despachado esparsamente.
Por que despachos esparsos, não spam por frame?
Inundar o React com 60 atualizações de estado/seg é um big-bang de renderização. Em vez disso, o Engine usa um buffer circular para rastrear tempos de frame, calculando FPS apenas quando o buffer completa (~a cada 30 frames).
if (this.frameCount % 30 === 0) {
const fps = 30 / (deltaSum / 1000);
this.store.dispatch(setFps(fps));
}
Resultado? Uma atualização atômica a cada ~500ms, zero jank, e Redux Devtools permanecem sãos. —sim, você leu certo: Quo.js suporta Redux Devtools—. Telemetria de performance deve servir a animação—não matá-la de fome.
Reproduzir localmente
pnpm add @quojs/core@0.2.0 @quojs/react@0.2.0
- Criar store (ver Passo 3).
- Envolver
<AppStoreContext.Provider value={store}>. - Montar Engine (Passo 5).
- Renderizar SVG Circles (Passo 6).
Docs para Quo.js e amigos:
Padrões pequenos mas impactantes
- Escritas em lotes por frame.
- Guarda de no-op em valores idênticos.
- Assinaturas a caminhos exatos.
- Guarda de tokens de geração rAF.
- Despacho esparso de telemetria.
Experimente!
⭐ Dê uma estrela ao Quo.js no GitHub
🧪 Experimente @quojs/core@0.2.0
🧪 Experimente @quojs/react@0.2.0
🐞 Registre issues / ideias
🧩 Contribua exemplos
Reflexões finais
Esta animação mostra como React pode ser um renderizador, não um loop de jogo. Lidando com mudanças de estado precisas e granulares, você obtém alto FPS, pouca agitação do React, e lógica mantível.
Uma nota rápida sobre os nomes dos hooks para useSliceProp e useSliceProps: Estes provavelmente mudarão para algo mais significativo como useAtomicProp e useAtomicProps antes do próximo lançamento (em uma semana), então não os use ainda em produção. Quo.js ainda está em testes beta.
Tenha um lindo coding, manu.
Licença: MPL-2.0 — compartilhe com o mundo.




Top comments (0)