Simple hand-drawn people. They breathe, blink, tilt their heads and sway their arms; people holding something keep the forearm bent up in front, and balloons bob. The edges re-wobble a few times a second, like a flipbook.
React · Motion
"use client";
import { useEffect, useRef, useState, type CSSProperties } from "react";
import { flushSync } from "react-dom";
import { createRoot } from "react-dom/client";
import { HumanDoodle, PEOPLE, type Person } from "./HumanDoodle";
// A crowd of sketched people, seen from the front, painted on a canvas so it
// stays smooth at thousands of people.
//
// 1. Sprites: every person in the cast is rendered once as an SVG (brush
// filter and all) at FRAMES moments of its idle loop, rasterised, and
// shrunk into a few mip levels. The line boil comes from changing the
// noise seed every other frame.
// 2. Layout: candidate spots on staggered rows (with perspective: each row
// back is smaller and higher), ranked by distance from the front center
// through a lumpy, noisy outline. The first n spots are the crowd, so it
// grows as an irregular blob, and spots never move as more people join.
// 3. Every frame: people back row first, each on its own loop phase. New
// people rise up from below and fade in.
//
// The first `people.length` people are all different; after that the cast
// repeats in a hashed order, half of them mirrored.
//
// <CountDial> is a compact count control to drive it: the pointer stays in
// the middle, hovering left of it counts down and right counts up.
export interface HumansDoodleProps {
count: number;
people?: Person[];
/** Height of a front-row person, px; the crowd shrinks below this to fit. */
personHeight?: number;
speed?: number;
roughness?: number;
ink?: string;
/** Changes the crowd's outline. */
shapeSeed?: number;
className?: string;
/** The canvas fills this box; give it a size. */
style?: CSSProperties;
}
const LOOP = 3.6; // seconds, the idle cycle every sprite frame samples
const FRAMES = 16;
const SPRITE_H = 240; // px, largest mip level
const FEET = 0.91; // feet line, as a fraction of the sprite height
// Layout, in units of a front-row person's height.
const PERSPECTIVE = 0.035; // row r is 1 / (1 + r * PERSPECTIVE) the size
const ROW_DEPTH = 0.12; // feet rise per row (scaled)
const SPACING = 0.33; // between neighbours in a row (unscaled)
const WIDE = 1.7; // the crowd spreads this much wider than it goes deep
interface Slot {
row: number;
x: number;
y: number; // rise of the feet above the front row
s: number; // scale
}
function hash(i: number, k: number) {
let x = Math.imul(i + 1, 374761393) ^ Math.imul(k + 7, 668265263);
x = Math.imul(x ^ (x >>> 13), 1274126177);
return ((x ^ (x >>> 16)) >>> 0) / 4294967296;
}
// Smooth 1D value noise in [0, 1).
function noise1(t: number, k: number) {
const i = Math.floor(t);
const f = t - i;
const u = f * f * (3 - 2 * f);
return hash(i, k) * (1 - u) + hash(i + 1, k) * u;
}
// ---- layout ----------------------------------------------------------------
const slotCache = new Map<number, { rows: number; slots: Slot[] }>();
/**
* The first n spots of the crowd, front center first. Spots are ranked by an
* elliptical distance (wider than deep) divided by a lumpy outline that
* varies with the angle, so the crowd grows as an irregular blob.
* Deterministic, so it is safe to call during render.
*/
export function crowdSlots(n: number, shapeSeed = 1): Slot[] {
const cached = slotCache.get(shapeSeed);
// Enough rows that every spot past them ranks after the n-th one.
const rows = Math.ceil(Math.sqrt((n * SPACING * ROW_DEPTH) / ((Math.PI / 2) * WIDE)) * 1.9 / ROW_DEPTH) + 6;
if (cached && cached.rows >= rows && cached.slots.length >= n) return cached.slots.slice(0, n);
const cand: (Slot & { score: number })[] = [];
let y = 0;
for (let row = 0; row < rows; row++) {
const s = 1 / (1 + row * PERSPECTIVE);
const z = row * ROW_DEPTH; // unscaled depth
const off = row % 2 ? 0.5 : 0;
const reach = (z + 1) * WIDE * 1.5;
const k = Math.ceil(reach / SPACING);
for (let j = -k; j <= k; j++) {
const wx = (j + off) * SPACING;
const a = Math.atan2(z + 0.15, wx); // 0..π, right to left
const lumps = 1 + 0.2 * (noise1(a * 2.2, shapeSeed) - 0.5) * 2 + 0.1 * (noise1(a * 6.5, shapeSeed + 1) - 0.5) * 2;
const e = Math.hypot(wx / WIDE, z);
const i = cand.length;
cand.push({
row,
x: wx * s + (hash(i, 3) - 0.5) * 0.25 * SPACING * s,
y: y + (hash(i, 4) - 0.5) * 0.15 * ROW_DEPTH * s,
s: s * (0.96 + hash(i, 5) * 0.08),
score: e / lumps + hash(i, 8) * 0.06,
});
}
y += ROW_DEPTH * s;
}
cand.sort((a, b) => a.score - b.score || a.row - b.row || a.x - b.x);
const slots = cand.map(({ score: _s, ...slot }) => slot);
slotCache.set(shapeSeed, { rows, slots });
return slots.slice(0, n);
}
function extents(slots: Slot[]) {
let minX = -0.35;
let maxX = 0.35;
let top = 1;
for (const p of slots) {
minX = Math.min(minX, p.x - 0.4 * p.s);
maxX = Math.max(maxX, p.x + 0.4 * p.s);
top = Math.max(top, p.y + p.s);
}
return { minX, maxX, top, bottom: 0.14 };
}
// ---- sprites ---------------------------------------------------------------
type Mips = HTMLCanvasElement[]; // largest first, each half the previous
function bakeSvg(person: Person, t: number, seed: number, roughness: number, ink: string) {
const host = document.createElement("div");
const root = createRoot(host);
flushSync(() => {
root.render(<HumanDoodle person={person} frozenAt={t} boil={false} seed={seed} roughness={roughness} ink={ink} size={SPRITE_H} />);
});
const svg = host.querySelector("svg")!;
svg.setAttribute("xmlns", "http://www.w3.org/2000/svg");
svg.setAttribute("width", String(SPRITE_H * 0.6));
svg.setAttribute("height", String(SPRITE_H));
const markup = new XMLSerializer().serializeToString(svg);
root.unmount();
return markup;
}
async function rasterize(markup: string): Promise<Mips> {
const img = new Image();
img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(markup)}`;
await img.decode();
const mips: Mips = [];
let w = Math.round(SPRITE_H * 0.6);
let h = SPRITE_H;
let src: CanvasImageSource = img;
while (h >= 20) {
const c = document.createElement("canvas");
c.width = w;
c.height = h;
const g = c.getContext("2d")!;
g.imageSmoothingQuality = "high";
g.drawImage(src, 0, 0, w, h);
mips.push(c);
src = c;
w = Math.round(w / 2);
h = Math.round(h / 2);
}
return mips;
}
// ---- crowd -----------------------------------------------------------------
export function HumansDoodle({
count,
people = PEOPLE,
personHeight = 120,
speed = 1,
roughness = 1,
ink = "#1b1a1a",
shapeSeed = 1,
className,
style,
}: HumansDoodleProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const countRef = useRef(count);
countRef.current = Math.max(0, Math.floor(count));
const opts = useRef({ personHeight, speed });
opts.current = { personHeight, speed };
// Sprites: sprites[person][frame], filled in progressively (frame 0 of
// everyone first, so the crowd shows up right away).
const spritesRef = useRef<(Mips | undefined)[][]>([]);
useEffect(() => {
let cancelled = false;
spritesRef.current = people.map(() => []);
(async () => {
for (let f = 0; f < FRAMES; f++) {
for (let c = 0; c < people.length; c++) {
// Yield first: flushSync can't render inside an effect, and this keeps
// the page responsive while baking.
await new Promise((r) => setTimeout(r, 0));
if (cancelled) return;
const seed = 3 + c * 5 + (Math.floor(f / 2) % 4) * 8;
const markup = bakeSvg(people[c], (f / FRAMES) * LOOP, seed, roughness, ink);
try {
spritesRef.current[c][f] = await rasterize(markup);
} catch {
/* skip a frame that failed to decode */
}
}
}
})();
return () => {
cancelled = true;
};
}, [people, roughness, ink]);
useEffect(() => {
const canvas = canvasRef.current!;
const ctx = canvas.getContext("2d")!;
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let W = 0;
let Hc = 0;
let dpr = 1;
let refit = true;
const resize = () => {
dpr = Math.min(2, window.devicePixelRatio || 1);
W = canvas.clientWidth;
Hc = canvas.clientHeight;
canvas.width = Math.round(W * dpr);
canvas.height = Math.round(Hc * dpr);
refit = true;
};
const ro = new ResizeObserver(resize);
ro.observe(canvas);
// Per-person appear/leave times (ms) for the rise-in.
let shown = 0;
let slots: Slot[] = [];
const born: number[] = [];
const leaving: { i: number; at: number }[] = [];
// Camera eases toward the fit for the current count.
let cam = { H: 0, cx: 0, baseY: 0 };
let target = cam;
let raf = 0;
const tick = (now: number) => {
raf = requestAnimationFrame(tick);
if (!W || !Hc) return;
const o = opts.current;
const n = countRef.current;
if (n !== shown || refit) {
if (n > slots.length) slots = crowdSlots(Math.max(n, Math.ceil(slots.length * 1.25)), shapeSeed);
if (n > shown) {
const firstRow = slots[shown]?.row ?? 0;
for (let i = shown; i < n; i++) {
const rowsBack = Math.max(0, slots[i].row - firstRow);
born[i] = now + Math.min(700, rowsBack * 25) + hash(i, 9) * 220;
}
} else if (n < shown) {
for (let i = n; i < shown; i++) leaving.push({ i, at: now + hash(i, 9) * 120 });
}
shown = n;
refit = false;
// Fit the crowd's box into the canvas.
const e = extents(slots.slice(0, Math.max(1, n)));
const H = Math.min(o.personHeight, (W * 0.94) / (e.maxX - e.minX), (Hc * 0.9) / (e.top + e.bottom));
const baseY = Hc / 2 + ((e.top - e.bottom) * H) / 2;
target = { H, cx: W / 2 - ((e.minX + e.maxX) / 2) * H, baseY };
if (!cam.H) cam = target;
}
const k = reduced ? 1 : 1 - Math.exp(-(1 / 60) * 6);
cam = {
H: cam.H + (target.H - cam.H) * k,
cx: cam.cx + (target.cx - cam.cx) * k,
baseY: cam.baseY + (target.baseY - cam.baseY) * k,
};
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, W, Hc);
// people, back row first
const sprites = spritesRef.current;
const time = now / 1000;
const draw = (i: number, alpha: number, rise: number) => {
const p = slots[i];
const cast = i < people.length ? i : Math.floor(hash(i, 1) * people.length);
const rate = o.speed * (0.88 + hash(i, 6) * 0.24);
const frame = reduced ? 0 : Math.floor(((time * rate) / LOOP + hash(i, 7)) * FRAMES) % FRAMES;
const mips = sprites[cast]?.[frame] ?? sprites[cast]?.[0];
if (!mips) return;
const h = p.s * (people[cast].height ?? 1) * cam.H;
const px = h * dpr;
let m = 0;
while (m + 1 < mips.length && mips[m + 1].height >= px) m++;
const x = cam.cx + p.x * cam.H;
const y = cam.baseY - p.y * cam.H + rise * h;
ctx.globalAlpha = alpha;
if (i >= people.length && hash(i, 2) < 0.5) {
ctx.save();
ctx.translate(x, 0);
ctx.scale(-1, 1);
ctx.drawImage(mips[m], -0.3 * h, y - FEET * h, 0.6 * h, h);
ctx.restore();
} else {
ctx.drawImage(mips[m], x - 0.3 * h, y - FEET * h, 0.6 * h, h);
}
};
const ease = (t: number) => 1 - Math.pow(1 - t, 3);
const items: { i: number; a: number; r: number }[] = [];
for (let i = 0; i < shown; i++) {
const t = reduced ? 1 : Math.min(1, Math.max(0, (now - born[i]) / 520));
if (t <= 0) continue;
const e = ease(t);
items.push({ i, a: e, r: (1 - e) * 0.45 });
}
for (let j = leaving.length - 1; j >= 0; j--) {
const { i, at } = leaving[j];
const t = Math.min(1, Math.max(0, (now - at) / 300));
if (t >= 1 || i < shown) {
leaving.splice(j, 1);
continue;
}
items.push({ i, a: 1 - t, r: t * t * 0.45 });
}
items.sort((a, b) => slots[b.i].row - slots[a.i].row || slots[b.i].y - slots[a.i].y);
for (const it of items) draw(it.i, it.a, it.r);
ctx.globalAlpha = 1;
};
raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
ro.disconnect();
};
}, [people, shapeSeed]);
return (
<canvas
ref={canvasRef}
className={className}
style={{ display: "block", width: "100%", height: "100%", ...style }}
role="img"
aria-label={`A crowd of ${count.toLocaleString()} sketched people`}
/>
);
}
// ---- count dial ------------------------------------------------------------
export interface CountDialProps {
value: number;
onChange: (value: number) => void;
min?: number;
max?: number;
/** Width in px. */
width?: number;
/** Seconds to sweep the whole range at full tilt. */
sweep?: number;
accent?: string;
className?: string;
style?: CSSProperties;
}
const STEPS = 1000;
/**
* A small count control: the pointer and the number stay in the middle while
* the ruler scrolls under them. Hover (or press) left of the pointer to count
* down, right to count up, faster the further out. The scale is exponential,
* so 1→20 is as easy to hit as 1,000→5,000. Arrow keys nudge it.
*/
export function CountDial({
value,
onChange,
min = 1,
max = 5000,
width = 200,
sweep = 2.4,
accent = "#6a5af9",
className,
style,
}: CountDialProps) {
const toValue = (p: number) => Math.round(min * Math.exp((p / STEPS) * Math.log(max / min)));
const toPos = (v: number) => (Math.log(Math.max(min, v) / min) / Math.log(max / min)) * STEPS;
const [pos, setPos] = useState(() => toPos(value));
const posRef = useRef(pos);
const velRef = useRef(0);
const onChangeRef = useRef(onChange);
onChangeRef.current = onChange;
const emitted = useRef(value);
const emit = (v: number) => {
if (v === emitted.current) return;
emitted.current = v;
onChangeRef.current(v);
};
// Follow outside changes to `value`.
useEffect(() => {
emitted.current = value;
if (toValue(posRef.current) !== value) {
posRef.current = toPos(value);
setPos(posRef.current);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [value, min, max]);
useEffect(() => {
let raf = 0;
let last = performance.now();
const loop = (now: number) => {
raf = requestAnimationFrame(loop);
const dt = Math.min(0.05, (now - last) / 1000);
last = now;
const v = velRef.current;
if (!v) return;
const next = Math.min(STEPS, Math.max(0, posRef.current + v * (STEPS / sweep) * dt));
if (next === posRef.current) return;
posRef.current = next;
setPos(next);
emit(toValue(next));
};
raf = requestAnimationFrame(loop);
return () => cancelAnimationFrame(raf);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sweep, min, max]);
const steer = (clientX: number, el: HTMLElement) => {
const r = el.getBoundingClientRect();
const t = ((clientX - r.left) / r.width) * 2 - 1; // -1 … 1
const dead = 0.12;
const m = Math.max(0, (Math.abs(t) - dead) / (1 - dead));
velRef.current = Math.sign(t) * m * m;
};
const stop = () => {
velRef.current = 0;
};
const nudge = (d: number) => {
const next = Math.min(STEPS, Math.max(0, posRef.current + d));
posRef.current = next;
setPos(next);
emit(toValue(next));
};
const shown = toValue(pos);
const tickGap = 9;
return (
<div
className={className}
role="slider"
tabIndex={0}
aria-label="Count"
aria-valuemin={min}
aria-valuemax={max}
aria-valuenow={shown}
onPointerMove={(e) => steer(e.clientX, e.currentTarget)}
onPointerDown={(e) => steer(e.clientX, e.currentTarget)}
onPointerLeave={stop}
onPointerCancel={stop}
onPointerUp={(e) => (e.pointerType === "mouse" ? undefined : stop())}
onKeyDown={(e) => {
if (e.key === "ArrowLeft" || e.key === "ArrowDown") nudge(-12);
else if (e.key === "ArrowRight" || e.key === "ArrowUp") nudge(12);
else return;
e.preventDefault();
}}
style={{
position: "relative",
width,
height: 58,
cursor: "ew-resize",
touchAction: "none",
userSelect: "none",
outline: "none",
...style,
}}
>
{/* the number, riding on the pointer */}
<div
style={{
position: "absolute",
left: "50%",
top: 0,
transform: "translateX(-50%)",
padding: "3px 10px",
borderRadius: 999,
background: "#1b1a1f",
color: "#fff",
font: '500 13px "JetBrains Mono", ui-monospace, monospace',
whiteSpace: "nowrap",
pointerEvents: "none",
}}
>
{shown.toLocaleString("en-US")}
</div>
{/* ruler, scrolling with the value, fading out at both ends */}
<div
style={{
position: "absolute",
left: 0,
right: 0,
bottom: 8,
height: 18,
background: `repeating-linear-gradient(90deg, #c4c4cc 0 1.5px, transparent 1.5px ${tickGap}px)`,
backgroundPositionX: `${-(pos * 0.9) % tickGap}px`,
maskImage: "linear-gradient(90deg, transparent, #000 30%, #000 70%, transparent)",
WebkitMaskImage: "linear-gradient(90deg, transparent, #000 30%, #000 70%, transparent)",
pointerEvents: "none",
}}
/>
{/* the pointer, fixed in the middle */}
<div
style={{
position: "absolute",
left: "50%",
bottom: 4,
width: 2.5,
height: 26,
marginLeft: -1.25,
borderRadius: 2,
background: accent,
pointerEvents: "none",
}}
/>
</div>
);
}