All posts

Design

A fixed canvas is why one component kit works everywhere

The reflex in 2026 is a fluid, responsive canvas that fits any screen. I kept doing the opposite: every VizLearn lesson draws on a canvas that is always exactly 1920×1080. It looked backwards at first, then turned into the constraint that let a hand-drawn component move from a sorting lesson to a neural net and still land in the right place.

March 15, 20265 min read

Every animation in VizLearn — a sorting race, a hash table colliding, a neural net you draw a digit on — is drawn on a canvas that is exactly 1920 by 1080 pixels. Not "1920 by 1080 on a desktop, something smaller on a phone." Always 1920 by 1080, on every device, in every lesson, and the number never changes. In 2026, when the default reflex for anything on a screen is to make it fluid and responsive, that sounds like an oversight. For a while it probably was. It turned into one of the most useful constraints in the whole project only after enough lessons started sharing the same pieces.

The constraint I thought I'd inherited

I didn't choose it on purpose, at first. VizLearn's drawing started life on Motion Canvas, and Motion Canvas works in a fixed-resolution coordinate space — you author against a set size. So I authored against a set size, the way you absorb a hundred defaults early on, because the tool worked that way and I had bigger problems to chase.

When I moved the rendering to Remotion, I assumed I was still stuck with it, and went looking for how to make the canvas responsive. Then I actually read the API. Remotion's <Player> takes compositionWidth and compositionHeight as plain props; I could set them to anything I wanted — the viewport, the container, a number the learner picks. The fixed size wasn't a limitation I'd inherited at all. It was a free choice I'd been making by accident.

So the choice I made next was to keep it fixed anyway. On purpose this time.

One coordinate system, or nine

Here's what I kept running into. A fixed canvas means every lesson speaks the same coordinate language, and that is what made a shared component kit possible.

Concretely: there's a constant in the codebase, PROBLEM_HEADER_LEFT_X = 160, that sets the left margin of a lesson's header. Because the canvas is always the same width, that 160 is the same physical place on the screen in a bubble-sort lesson and in a hash-table lesson. The three horizontal bands every lesson uses — a header up around y=50, a status panel around y=120, the main stage from y=220 down — sit at the same coordinates in all of them. So a Cell, a LabeledPointer, a StatusPanel can be written once, with real absolute numbers baked into them, dropped into any lesson, and land exactly where they should. The kit became the visual language of the product, and that language only works because every speaker agrees on what x = 160 means.

Now make the canvas fluid and watch what happens. 160 stops meaning anything — 160 out of 1920, or out of 1280, or out of a phone's 390? Every component has to stop using coordinates and start recomputing them from the current viewport. The shared Cell fractures into per-lesson layout math. The numbers in the layout rules turn into vague suggestions. Nine lessons that felt like one product slide back into nine demos that happen to share a color palette. The fixed canvas is one of the things that kept them feeling like one product.

Responsive display, fixed coordinates

The obvious objection is that screens aren't 1920×1080, so a fixed canvas should look terrible on a phone. This is the part that makes the whole approach work, and it's worth being precise about: the fixed size is the authoring coordinate space, not the display size.

A Remotion <Player> renders its 1080p composition and then scales the entire thing to fit the box you hand it, with a CSS transform. On a 4K monitor it scales up, still crisp. In a narrow column it scales down. On a phone it shrinks to fit. I write exactly zero responsive layout code to make any of that happen — the player does it, because scaling one composition to fit a container is a solved problem the instant the composition has a known, fixed size. Fixed coordinates underneath; responsive display on top. It isn't a trade between the two. You get both, and the "both" is only on the table because the coordinate space is frozen.

Fixed frame, fluid contents

There's one distinction that took me longer to get clean, and it's the one most people skip. A fixed canvas does not mean fixed content.

Inside that frozen 1920×1080 frame, the content absolutely has to flow. A sorting lesson runs with five bars or with eleven; a linked list has three nodes or nine. The bars and cells and gaps must rescale to the count every single time — a layout that only looks right at the default input size is treated as a bug. So the discipline splits cleanly along two axes. The frame is frozen, so components can share a coordinate system. The contents are fluid, so they fit whatever data the learner throws at them.

Conflating those two axes is exactly where the instinct goes wrong. When a lesson feels cramped at eleven bars, the reflex is "the canvas should be responsive" — but the canvas was never the problem. The contents needed to scale inside a frame that should stay exactly where it is. Make the whole frame fluid to fix a contents problem and you've thrown away the shared coordinate system to solve something that lived one level down from it.

The constraint became the feature

The instinct underneath all of this is that flexibility is freedom — that a responsive, fits-anything canvas is strictly better than a rigid one. But a coordinate system is only useful if everything agrees on it, and agreement is a constraint by definition. Freezing the canvas is the constraint that made the component kit, the layout rules, and the one-product feel possible in the first place. The flexibility I gave up bought me something I kept needing more: pieces that compose.

So the canvas is 1920×1080, and I've stopped trying to make it anything else. Not because I can't — it's one prop — but because the fact that I don't is what holds the rest of the product together.