How I built the Quo.js logo animation with an Engine outside React, atomic subscriptions inside React, and almost zero boilerplate.
TL;DR: I turned a static PNG of Quo.js logo into hundreds of animated SVG circles that assemble, disperse around the mouse, and glide home — all in React 19 + TypeScript using @quojs/core@0.2.0 and @quojs/react@0.2.0. The trick is running a small Engine completely outside React, streaming batched state updates into a Quo.js store, and then rendering with atomic subscriptions in React so the UI only re-renders when a specific circle’s coordinates actually change.
Tech: React 19, TypeScript, Vite, SVG
State: @quojs/core@0.2.0 and [@quojs/react@0.2.0](https://github.com/quojs/quojs/blob/main/packages/react/README.md_**What’s not used**: three.js, WebGL, WebGPU. This is pure DOM/SVG + requestAnimationFrame.
What you’ll see (GIFs)
If you visit Quo.js' website, you will see this:
-
Intro: particles converge into the “Quo.js” logo mark.
And if you play with the cursor:
-
Interactivity: circles orbit away from the cursor, then relax back to “home.”
Dev.to might compress the GIF files, if you see sluggish animations, consider downloading the GIF files (intro and interaction) or visiting https://quojs.dev for buttery 60fps.
The idea in one diagram
We keep React lean by pushing all animation work into a plain, simple TypeScript nano-Engine. The Engine owns the frame loop, quadtree, and motion math. It sends batched updates to a Quo.js Store. React consumes only the tiny slice it needs per circle via atomic selectors.
- Engine (outside React): requestAnimationFrame loop, dt clamp, FPS smoothing, quadtree, circle physics.
- Quo.js Store: purely functional reducers, effects for small timings and lifecycle events.
-
React: just renders
circlenodes viauseSliceProp('logo', 'd.circle_0')etc.
Why this design? (Performance & simplicity)
-
React stays idle unless needed. Each
<Circle>component subscribes to its ownx,ypath. No global re-renders. No prop drilling. -
Engine is framework-agnostic. It doesn’t know about React. It just dispatches actions (
batchUpdate) to the store. - Less boilerplate than Redux/RTK. Quo.js uses channels + events, effects without thunks/sagas, and atomic selectors to subscribe at exact dotted paths.
- Batched updates per frame + a tight reducer keep state writes minimal.
- rAF + dt clamp + FPS ring buffer give smooth motion without stutters.
Step 1 – Model the state and channel events
We keep state logo For the animation.
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: {};
};
Dispatch pattern:
store.dispatch("logo", "batchUpdate", { changes: [...] });
Step 2 – The reducer: immutable, fast
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>[];
// suggar for type inference
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];
// insert
if (!prev) {
const nextGroup = { ...groupMap, [next.id]: next };
return { ...state, [group]: nextGroup };
}
// update only if something actually changed
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;
}
},
};
- Avoid writes if
x,yunchanged. - Batch many updates per frame.
- Only touch changed keys.
Step 3 – Create the store
export const store = createStore({
name: "Quo.js",
reducer: { logo: logoReducer },
effects: [],
});
Step 4 – The Engine (outside React)
- Runs rAF loop.
- Computes dt and FPS.
- Dispatches
logo/batchUpdate. - Subscribes to store effects for start/stop.
- Never imports React.
if (dt > 0) this.simulation.loop(this._dt, now);
if (this._running && gen === this._rafGen) {
this._handle = requestAnimationFrame((t) => this._tick(t, gen));
}
Each Circle emits { group, id, x, y } updates when it moves.
Step 5 – Bridge Engine and Store in 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]);
Hold-on, manu—how do we turn a PNG into a swarm of circles?
Under the hood, extractCircleSpecsFromImage loads the logo PNG into an offscreen canvas, samples pixel alpha at a configurable grid (default: 8px spacing), and emits a circle spec wherever alpha > 50.
const threshold = 0.2; // 0–1, tweak for density
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 });
}
}
}
Want a denser swarm? Crank down spacing. Want mood lighting? Map alpha → radius. It’s just data—hack away.
Step 6 – React glue with @quojs/react
export const { useStore, useDispatch, useSliceProp } =
createQuoHooks(AppStoreContext);
Now each <Circle> subscribes atomically:
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`} />;
};
Only that circle re-renders when its own coords change.
Data flow recap
FAQ / Notes
- Why not RTK? Needed channel/event model, async effects, atomic subs.
- Why not three.js/WebGL? Overkill for 2D particles; SVG + Quo.js is lean.
-
Intro completion? Simulation tracks remaining circles → dispatches
introProgress→introComplete. - FPS? Averaged via ring buffer, dispatched sparsely.
Why sparse dispatches, not per-frame spam?
Flooding React with 60 state updates/sec is a render big-bang. Instead, the Engine uses a ring buffer to track frame times, computing FPS only when the buffer wraps (~every 30 frames).
if (this.frameCount % 30 === 0) {
const fps = 30 / (deltaSum / 1000);
this.store.dispatch(setFps(fps));
}
Result? One atomic update every ~500ms, zero jank, and Redux Devtools stay sane. —yes, you've read it: Quo.js supports Redux Devtools— . Performance telemetry should serve the animation—not starve it.
Reproduce locally
pnpm add @quojs/core@0.2.0 @quojs/react@0.2.0
- Create store (see Step 3).
- Wrap
<AppStoreContext.Provider value={store}>. - Mount Engine (Step 5).
- Render SVG Circles (Step 6).
Docs for Quo.js and friends:
Small but impactful patterns
- Batch writes per frame.
- No-op guard on identical values.
- Exact-path subscriptions.
- Guard rAF generation tokens.
- Sparse telemetry dispatch.
Try it out!
Closing thoughts
This animation shows how React can be a renderer, not a game loop. With handling precise, granular state changes, you get high FPS, low React churn, and maintainable logic.
A quick note on the hook names for
useSlicePropanduseSliceProps: These will probably change to something more meaningful likeuseAtomicPropanduseAtomicPropsbefore the next release (in a week), so do not use them yet in prod. Quo.js is still in beta testing.
Have a nice coding, manu.
License: MPL-2.0 — share with the world.


Top comments (0)