Engineering
I wanted a paintbrush, not a drawing app
VizLearn's hand-drawn look started life on the Excalidraw npm package — until it became clear I'd adopted a whole drawing app when what I needed was a paintbrush. This is the detour through roughjs and Remotion, the bug that made my rounded rectangles look terrible, and the two Excalidraw functions I kept (and credited).
You can tell a lot about a project by what its author reaches for first. I wanted VizLearn's animations to look hand-drawn — a little wobbly, a little human, the opposite of the cold vector-perfect diagram you scroll past without reading. There is one obvious answer to "I want the hand-drawn look on the web," and it's Excalidraw. I love how Excalidraw looks. So that's where I started — back before any of this was its own project.
My first attempt at the idea — a prototype from before VizLearn had a name — was built on @excalidraw/excalidraw, the npm package: the actual editor component, dropped into the page, with the animation code reaching in to mutate its scene. Every frame, I'd compute the new state of the algorithm and push the elements into Excalidraw to redraw. It worked, in the demo-on-a-Friday sense. Then it stopped working, for a reason that took me embarrassingly long to say out loud.
An editor is not a paintbrush
The package is an editor. That sounds obvious written down, but it took me a while to let that obvious fact matter. @excalidraw/excalidraw is a complete drawing application: it owns its own state, its own history stack, its own render loop, its own ideas about what a user is doing with a mouse. It hands you a finished thing — a canvas you draw on. What it does not hand you is the part underneath.
And the part underneath is the entire product. VizLearn is a debugger, not an animator: the point isn't that bars slide around, it's that you can pause on a single step and see the visual stage, the highlighted line of code, and the log all pinned to the same instant. That demands a timeline you can scrub, step boundaries you can stop on, a play button that drives three different engines, code highlighting synced frame-by-frame to the animation. None of that is drawing. All of it is playback control — and an editor that owns its own render loop gives you nowhere to put it. I wasn't extending Excalidraw; I was fighting it for control of a loop it had no intention of sharing.
There's a smaller, more technical version of the same problem. VizLearn renders through Remotion (which I landed on later, over Motion Canvas, for a separate set of reasons), and Remotion is built on one promise: given a frame number, draw exactly that frame, deterministically, every time. An interactive editor with its own internal animation and a mutable state blob is the opposite of deterministic-frame-from-a-number. Re-pushing the whole scene on every tick to fake an animation was heavy, and it was always slightly fighting the editor's own sense of time.
So the diagnosis became simple, once I stopped flinching from it: I had reached for a drawing app when what I needed was a paintbrush. I needed to own every pixel of every frame and the playback wrapped around them. Excalidraw the editor couldn't give me that, because giving you that isn't its job.
Dropping down to the paintbrush
The good news is that the paintbrush exists, and Excalidraw uses it too. Underneath the hand-drawn aesthetic is Rough.js, a small library that sketches shapes with a deliberate human wobble. That's the actual primitive — the thing that makes a line look drawn instead of plotted. So I threw out the editor and built straight on roughjs: a thin RoughCanvas that takes shapes and draws them, with VizLearn owning the frame clock and the whole transport bar on top.
This is the trade you make when you stop using a finished tool and start using the primitive it was built on. You get total control. You also inherit every problem the finished tool was quietly solving for you. I found the first one immediately.
My rounded rectangles looked terrible
Almost every element in VizLearn is a rounded rectangle — a cell, a node, a panel. So the very first thing I drew was a rounded rectangle, and it looked awful.
My naive version drew the shape in pieces: four straight edges, then little arcs to round off the corners. On a clean vector canvas that's fine. On a hand-drawn canvas it falls apart, for two reasons that compound each other. First, the seams: each piece is its own stroke, so the corner arc and the edge it's supposed to meet don't actually line up — they overshoot, undershoot, leave gaps. Second, roughjs is supposed to wobble, and by default it randomizes the vertices of whatever you give it, so the two endpoints that were already not quite meeting now jitter apart on every render. The result reads less "hand-drawn" and more "drawn by a hand that's had a few."
The fix is the thing reading Excalidraw's renderer taught me, and it's almost annoyingly simple: don't draw the rectangle in pieces. Build the entire outline — edges and rounded corners together — as one continuous SVG path (M, L, the corners as Q quadratic curves, close with Z) and hand that single path to roughjs. One continuous stroke can't have a seam, because there's nothing to seam. And the second half: tell roughjs preserveVertices: true, so it stops randomizing the corner points and leaves them where the path put them. Wobble the line between the corners, not the corners themselves.
That path builder is generic — it's the kind of code anyone ends up writing once they know the trick, and the version in VizLearn is my own. What I took from Excalidraw was the insight, not the lines, surfaced by an AI I pointed at its renderer rather than by reading it myself — the same way I found the two constants below.
The two functions I actually kept
But there were two places where I didn't just borrow the insight — I ported the code, because the code was the insight, sitting in numbers I'd never have guessed.
The first is corner radius. How round should a corner be? Pick a fixed radius and small boxes look fine while big boxes look nearly square. Pick a fixed proportion and big boxes round into pills. Excalidraw's answer is a hybrid: below a cutoff, the radius is 25% of the shorter side; above it, the radius caps at a flat 32px. The clever part is the cutoff, which is 32 / 0.25 = 128 — chosen so that at the switchover point the proportional curve (128 × 0.25 = 32) exactly meets the cap. No discontinuity, no visible jump. Corners that look right at every size. You do not arrive at 0.25 and 32 by intuition; you arrive at them by reading someone who already solved it.
The second is roughness. The hand-drawn wobble is a fixed amount of jitter, and a fixed amount of jitter that looks charming on a 200px box looks like a scribble on a 16px one — the shape can't even close. Excalidraw scales it down: above a size threshold, full wobble; below it, divide the roughness by two (by three if the shape is truly tiny), capped so it never blows past 2.5. Small shapes stay legible instead of disintegrating.
Both are a dozen lines. Both are ported almost verbatim into VizLearn's drawing layer, and both carry the magic constants that make them work. That makes them Excalidraw's expression, not mine — which brings up the only part of this with a right and a wrong answer.
Standing on shoulders, with the receipt
Excalidraw is MIT-licensed, and so is roughjs. MIT is about as permissive as a license gets: you can copy, modify, and ship the code, in a commercial product, with nobody to ask first — the license itself is the permission. It asks for exactly one thing in return — keep the copyright notice and license text around when you include a meaningful chunk of it.
I treat two ported functions, fingerprint constants and all, as a meaningful chunk. So the honest move, and the one I'd want someone to make with my code, is to write it down: VizLearn has an open-source licenses page that names Excalidraw and roughjs, credits their authors, and includes their licenses in full, and the ported functions say where they came from right there in the source. It costs nothing and it's just true. "I learned this from them" and "I copied twelve lines from them" are different sentences, and the second one comes with a small, easy obligation. Pay it.
None of this is a knock on Excalidraw — the opposite. VizLearn isn't affiliated with them in any way; it just owes their software, and their ideas, a real debt. I started on it because it's the best hand-drawn canvas on the web, I switched off it only because I needed to own a render loop it rightly keeps to itself, and the thing I built instead still looks good because I went back and read how they did it. That's the arc I took: use the finished tool until it stops fitting, drop to the primitive when you need control, and when you get stuck making the primitive look good, read the people who got there first — and leave a credit on your way out.