It Worked in a 280-Pixel Box

The navigation on this site isn't a dropdown menu. It's an orbital map: Soren Lab at the center as the sun, the core applications orbiting as planets, individual pages orbiting those as moons. You navigate the company by navigating a solar system.
I built it because I'm asking people to believe a solo studio can produce company-level work, and the cheapest way to make that claim is to write it on an about page. The expensive way is to put something on the homepage you couldn't get from a template.
Version one was SVG, and it was fine. React rendered an wrapper; each body was a group, and I animated transforms through refs. In the docs app, the map lives in a 280-pixel sidebar, and at that size it was smooth and easy to reason about. I shipped it and moved on for weeks.
It wasn't a bad decision. SVG was right for the thing I'd built. The problem was building a different thing with it.
Then it met a full-screen hero. The marketing site needed the same constellation as a full-viewport ambient background. Same component, bigger box, two failures.
SVG sizes through viewBox coordinate scaling: a contain-fit model, where the whole coordinate space stays visible and the browser pads the mismatch. Backgrounds want cover-fit: fill the viewport, crop the overflow. On a 21:9 monitor, I got letterbox bars, and every workaround was a transform hack that degraded rendering somewhere else.
The second was DOM overhead. The full map is around 15 bodies plus orbit rings, connection vectors, and hover targets; call it 50+ nodes whose attributes update inside an animation loop. A 280px sidebar absorbs that. Full-screen, every attribute write is a style recalculation on the main thread, and the browser does layout work sixty times a second for an animation that conceptually never touches layout.
Neither is a bug. They're properties of SVG that don't matter at one size and dominate at another. So I deleted it and wrote a raw HTML5 canvas engine.
What the package is. A zero-dependency presentation package that exports raw TypeScript and TSX from src/ rather than a compiled bundle, so each app compiles it against its own build target. Split by responsibility: orbit physics, camera transforms, color resolution, the starfield, the animation loop hook, the node graph, and the React component.
The split that earns its keep is the physics module having no DOM or React references at all: pure functions over plain objects, deterministic and testable without a browser. When the orbits looked wrong, I could test the math in isolation instead of squinting at a canvas trying to infer what a number was doing.
Nested orbits need a breadth-first walk, not depth-first. Moons read their planet's position each frame, and parents have to resolve first; otherwise, the moons trail their planet by exactly one frame. Subtle, and the kind of thing you stare at for an hour blaming the easing.
The damping function I got wrong first. Camera pan and zoom need easing, and the version everyone writes is current += (target - current) * 0.1, move ten percent of the remaining distance per frame. It reads fine,e and it's frame-rate dependent: ten percent per frame is twice as fast on a 120Hz display.
Same bug family as the animation speed problem, in a different place, written without thinking because it's the shape every tutorial uses. The fix expresses easing in time instead of frames:
typescript
export function dampTowards(current: number, target: number, halfLife: number, dt: number): number {
const t = 1 - Math.pow(2, -dt / halfLife);
return current + (target - current) * t;
}halfLife is how long it takes to cover half the remaining distance in real time, not frames. A 200ms half-life is 200ms on every machine. Four lines, exactly correct, and it makes a whole category of hardware-dependent bugs impossible.
The invisible element that paints the canvas. This is my favorite part. The site runs on CSS custom properties, shared across five apps, and canvas ignores all of it. ctx.fillStyle takes a color string and cannot parse hsl(var(--brand-gold)).
The lazy fix is hardcoding hex in JavaScript, which works and gives you two sources of truth for your brand colors that drift the moment someone adjusts the CSS palette without knowing the canvas keeps its own copy.
So instead, I ask the browser. An invisible element, mounted in the document, exists purely to be asked what a color actually resolves to:
typescript
export function resolveCssColor(cssVar: string, probeEl: HTMLElement): string {
probeEl.style.color = cssVar;
const computed = getComputedStyle(probeEl).color;
return computed || FALLBACK_HEX;
}Assign the variable, let the browser resolve the cascade, read back a concrete RGB string. getComputedStyle forces a style recalculation, so it runs once on mount and on theme change, and caches; the draw loop never touches it. CSS stays the single source of truth, and the canvas asks rather than copying.
The performance crisis, and the bug underneath it. I've written up the 120Hz problem already. The implementation is a delta-time gate: requestAnimationFrame still drives pacing, but the physics and redraw only run once 33.3ms have elapsed. Subtracting the remainder rather than resetting to the current timestamp keeps errors from accumulating into visible drift. On a 120Hz display, a quarter as many draw passes.
The second bug was worse. In the docs and blog apps, the map is a sidebar widget, and scrolling into a long article takes it out of the viewport entirely,y where it kept rendering at full rate, for a widget nobody could see, for as long as someone read the page.
Throttling made that four times cheaper. It didn't make it less absurd. The right number of frames for something off-screen is zero, so the component takes a paused prop driven by an IntersectionObserver, and the loop short-circuits.
That contrast stuck with me. The 120Hz bug needed hardware I don't own. This one was ordinary behavior for someone scrolling, happening on every article view since the day it shipped. The bug I could only reproduce on a machine I didn't have got fixed first.
The maps had quietly drifted. Each of the three public apps started with its own local copy of the galaxy map, which seemed reasonable since each app knows its own routes. Months later, docs didn't know about the blog's new sections and the marketing site still had placeholders for things that had shipped. Three maps of one company, none agreeing; no error anywhere, a stale node graph is still a valid node graph.
Now one builder function generates it, and the current app decides whether links resolve relative or absolute cross-domain. The entire point of a monorepo is shared packages, and I'd still written the same data three times because copying a small JSON file felt cheaper than designing a shared one. It's cheaper for about six weeks.
Why it matters. A canvas navigation engine wasn't on any checklist. A dropdown would have worked. But the pitch is that a local shop gets the same quality of build a funded startup gets, and I'd rather demonstrate that than assert it, which only counts if the visible part is held to the same standard as the parts nobody sees. The navigation gets frame-rate independence and a single source of truth for the same reason the database layer gets connection pooling.
Something beautiful that runs your fans hot isn't craft. It's a demo.
The full version is on Patreon. Everything essential is here.
New posts, in your inbox
Roughly one a week. Confirm once, unsubscribe anytime.