A page that needed a crowd of people walking across it could have reached for a lot of machinery: a WebGL particle system, a physics library for the gait, a hundred absolutely-positioned DOM sprites each fighting the browser's layout engine on every frame. It needed none of it. A single flat 2D canvas, one sprite sheet, and a loop disciplined enough to know when not to run turned out to be the entire solution — proof that the fanciest available tool is rarely the correct one, and that 'game engine' problems are often just 'draw a rectangle at the right time' problems wearing a scarier name.
The brief that wasn't WebGL
The instinct, faced with 'animated crowd of figures,' is to reach for whatever tool feels most capable — WebGL, a game engine, a physics-driven skeleton for each walk cycle. Almost none of that was needed here. The figures don't need lighting, depth, or genuine physics; they need to look like they're walking, repeatedly, at a believable pace, without costing the page a noticeable chunk of its frame budget. That's a much smaller problem than it first sounds like, and 2D canvas is squarely built for exactly this: draw calls, cheap enough to issue dozens of times a frame, with none of a DOM node's layout and paint overhead.
The other reason canvas won over DOM sprites is compositing. A hundred DOM elements each animating position independently means the browser has to consider all of them for layout and paint on every tick, even when most of the work is trivial. A single canvas is one paint target as far as the browser is concerned — everything happening inside it is the page's problem to manage, not the browser's, which is exactly the trade you want once the number of moving things climbs past what individual DOM nodes handle gracefully.
One sprite sheet does the acting
The entire cast comes from one image: a grid of walk-cycle frames across a handful of figure variants, drawn once, sliced at runtime by simple arithmetic on the sheet's row and column dimensions. Each figure on screen is nothing more than a small state object — an x position, a walk speed, a row index into the sheet, and a current frame — and every tick, the canvas draws a cropped rectangle from the sheet at that figure's frame and position. No figure owns a DOM node, a class, or a stylesheet. It owns four numbers and a draw call.
This is where the 'no game engine' claim earns its keep: a real walk-cycle animation system would model joints, interpolate skeletal poses, blend between gaits. A sprite sheet skips all of that by having an artist do the hard part once, offline, as a strip of pre-drawn frames. The runtime's entire job is picking the right rectangle at the right time and stepping through it in order — arithmetic, not simulation. It's a deliberately old technique, older than the web itself, and it's still correct here because the visual target — a small, stylised, slightly abstracted figure — never asked for anything a sprite strip couldn't deliver.
The ticker is the only clock
Every figure's walk cycle and horizontal drift are driven off a single shared clock rather than each spawning its own requestAnimationFrame loop or setInterval. GSAP's ticker already exists on the page for every other piece of motion, and reusing it here means the crowd's frame advance and the rest of the page's animation are governed by the same heartbeat — no drift between systems, no redundant rAF callbacks competing for the same frame budget. One tick, once per frame: advance every figure's position, and advance its walk-cycle frame index only on a slower cadence than the position update, because a walk cycle sampled at a lower frame rate than the movement itself still reads as fluid to the eye and costs meaningfully less canvas work per second.
The eye forgives a walk cycle sampled at a slower rate than its motion. It does not forgive a dropped frame in the scroll it's standing on.
That distinction — sample the animation cheaply, never sample the page's responsiveness cheaply — is the actual performance discipline underneath the whole component. A crowd that looks slightly less fluid up close is an acceptable trade. A crowd that makes the rest of the page stutter is not, and canvas work is cheap enough, done carefully, that the trade rarely needs making at all.
Culling what you can't see
The single biggest performance win has nothing to do with how the crowd is drawn and everything to do with when. The canvas sits inside an IntersectionObserver, and the entire tick loop is gated on visibility: scrolled out of the viewport, the loop simply doesn't run. No figures update, no rectangles get drawn, no work happens at all — not throttled, not reduced, stopped. A crowd animating patiently below the fold that nobody is looking at is pure waste, and the fix is not a cleverer draw call, it's not drawing anything until a visitor scrolls close enough for it to matter.
This is the same discipline that shows up everywhere in the motion system on this site — every hook gets an early exit, whether the gate is prefers-reduced-motion or simple visibility — and it matters more for canvas than almost anywhere else, because a canvas loop left running off-screen doesn't just waste a frame here and there, it keeps a rAF-scale loop alive at full rate for the entire time it's out of view. Gating it isn't an optimisation bolted on afterward. It's the difference between a component that costs something only when it's earning its keep, and one that costs something constantly out of habit.
When canvas is the wrong answer
None of this is an argument that canvas beats WebGL, or that sprite sheets beat skeletal animation, in general. The moment a scene needs real depth, lighting that responds to a camera, or genuinely three-dimensional geometry — which is exactly the case for the liquid blob elsewhere on this site — WebGL is the only tool built for the job, and reaching for flat canvas there would mean faking depth badly instead of rendering it properly. Canvas earned its place here specifically because the crowd is flat, repetitive, and stylised: three properties that make a 2D sprite sheet not just adequate but actually superior to a heavier pipeline that would spend GPU budget on realism nobody asked for.
The lesson generalises past this one component: match the rendering technology to what the content actually needs, not to what sounds most capable in a planning meeting. A crowd of walking figures is a decades-old kind of problem with a decades-old, extremely cheap solution. Reaching past it for WebGL wouldn't have made the crowd look better. It would only have made it cost more to build and more to run.
Frequently asked questions
Why use 2D canvas instead of WebGL for animated sprites?
WebGL earns its cost when a scene needs real depth, lighting or three-dimensional geometry. Flat, repetitive, stylised sprite animation — like a crowd of walking figures — doesn't need any of that, and 2D canvas draw calls are cheaper to issue and reason about. Reach for WebGL when the content is genuinely three-dimensional, not by default.
How do sprite sheets work for character animation?
A sprite sheet is a single image containing every frame of an animation laid out in a grid. At runtime, code slices out one rectangular frame at a time based on row and column arithmetic and draws it to the canvas, stepping through frames in sequence to create the illusion of motion. It trades a real-time animation system for pre-drawn frames, which is far cheaper to run and easier to art-direct precisely.
Why gate a canvas animation loop with an IntersectionObserver?
A requestAnimationFrame-driven canvas loop keeps running at full rate even while scrolled out of view unless something stops it. Gating the loop on visibility means an off-screen animation costs nothing at all rather than continuing to consume frame budget the user can't see, which matters far more for a persistent canvas loop than for most other kinds of motion.