---
title: "I wanted a component, not a studio"
description: "VizLearn's first three months ran on Motion Canvas, not Remotion. I switched when learner input, scrubbing, and code-sync all needed to live inside the app — and the detour taught me to pay attention to the boundary I was building at, not just the output format."
source: https://vizlearn.app/blog/component-not-a-studio
date: 2026-03-09
---

# I wanted a component, not a studio

VizLearn's very first commit — November 5th, 2025, the day the project got its name — built the whole thing on [Motion Canvas](https://motioncanvas.io/), not [Remotion](https://www.remotion.dev/). For three months, every bar that slid and every line that lit up ran through Motion Canvas's renderer, and I was happy with it. Then I tried to let a learner type their own array into the box, and the floor quietly fell out.

Here's what I didn't understand in November. Motion Canvas and Remotion look like the same kind of tool: both animate frame by frame in TypeScript, both will happily put a hand-drawn shape on a canvas, both export video. But they're built to live at different boundaries. One is a studio you author a video *in*. The other is a component you drop *into* an app. VizLearn isn't a video. It took me three months, one stubborn input box, and a wrong turn through my own engine to feel the difference between those two sentences.

## Motion Canvas is a genuinely beautiful way to make a video

I want to be fair to it, because it's good and I left it for reasons that aren't its fault. You write a scene as a generator function, and you animate by yielding time:

```ts
export default makeScene2D(function* (view) {
  yield* all(
    bar().fill(comparing, 0.4),
    bar().position.y(top, 0.4),
  );
  yield* waitFor(1);
});
```

That reads top to bottom in the same order time moves. No frame arithmetic, no "what's my opacity at frame 47" — you say *do these together over 0.4 seconds, then wait a beat*, and that's literally the code. It has first-class video export through ffmpeg sitting right in the toolchain. For authoring a linear explainer to render out as an mp4, this is a lovely way to work, nicer than thinking in frame numbers.

And it carried real weight. By February I'd built bubble sort, a responsive layout system, a set of hand-drawn `Rough*` components, even a folder of director's scripts. It worked. It looked right. The aesthetic VizLearn ships today was already there.

## But I wasn't making a video

VizLearn is [a debugger, not an animator](https://vizlearn.app/blog/debugger-not-an-animator). The learner doesn't watch a recording play — they drive. They scrub to step 7, stop, read the highlighted line, change the input, run it again. That's an instrument, not a recording, and an instrument needs random access into time.

This is exactly where a generator stops being a gift. A Motion Canvas scene's timeline *is* the generator's execution — the order its `yield*`s happen in. It can render any given frame, of course; what it doesn't hand me is a clean product boundary — a place where my React app says *here's the learner's input, here's frame n, give me the lesson state for that instant* and gets it back as a value. To reach step 7 you play the scene forward through seven yields. Remotion *is* that boundary: a frame is a pure function of its number, so frame 210 is one cheap call, every time, in any order. A generator is a tape; I needed an index. You can fast-forward a tape, but you can't *open it to a page*.

## The input was pointing the wrong way

This is the one that finally moved me, and it's sharper than the loop problem.

Motion Canvas does let you parameterize a scene — Player Variables:

```ts
const array = scene.variables.get('array', [30, 58, 12, 91, 47, 76, 23])();
```

Two things about how I used that line. First, in my scene I read it *once*, at the very top of the generator, and built the entire animation from the snapshot it returns. Second, the way you actually set the value is to open the Player's "Variables" panel — a JSON box in the dev tool — type an array, and let the scene re-render.

Read that again: a JSON box in the dev tool. That's the whole tell. Player Variables is an affordance for *me*, the author, sitting in the studio. It points inward. What VizLearn needs points the other way entirely — a *learner*, in the *product*, typing into an Inputs panel, with the animation reacting under their hands. And the way I'd built it — read the value once, grow the whole scene from it — meant "reacting" was never a live wire from a control to a running animation. It was tearing the scene down and rebuilding it from scratch on every change. Snapshot and restart, not a knob you turn.

I don't have to reconstruct how I felt about this, because I wrote it down at the time. In that project's design doc, under a heading literally titled *Future directions → Interactive mode*, the first bullet is: "user inputs a custom array." I had filed the single most central thing my product needed to do under *someday*. That's the moment it should have clicked, and it took me a while longer.

Now the same thing in Remotion. Custom input is props:

```tsx
<Player component={BubbleSort} inputProps={{ array }} durationInFrames={fps * 12} />
```

The Inputs panel writes React state, the state flows in as `inputProps`, and every frame's `useCurrentFrame()` recomputes from it. No JSON box, no studio panel, no teardown — the input points outward, from the app into the animation, because a `<Player>` is a component built to take props from whatever surrounds it. The thing that sat on my wishlist for three months is what Remotion does on line one.

## I could make it play. That was never the wall.

Here's the part I skipped, and it's the part I'm least proud of. When Motion Canvas wouldn't bend to what I needed, my first instinct wasn't to go find a tool that would. It was to build the missing pieces myself, on top of the one I already had. I wrote my own player from scratch, reverse-engineered from Motion Canvas's source and bundle with an AI rather than wrapped around the official one, and it handed me real play and seek control — so let me be precise about where the wall actually stood, because it wasn't there. I *could* make Motion Canvas play, and I could even make it seek. Playback was never the problem.

The problem was that the product wanted to be a debugger, and a debugger's learner input, trace state, code highlight, log, and quiz all want to be *first-class React state* — not side effects buried inside an authored scene. That's a boundary in the wrong place, not a missing feature, and I couldn't patch my way across it. I tried anyway: the custom player grew into a whole from-scratch animation engine — my own scene graph, timeline, and player, 679 lines that quietly turned into a second, worse Motion Canvas. It even worked, which is exactly what made it a trap. That detour is [its own story](https://vizlearn.app/blog/dependency-not-an-engine); the one-line version is that rebuilding your tool is the loudest signal you picked the wrong one, and it took me a month of building to hear it.

## Three months in, the same bubble sort

So I rebuilt bubble sort on Remotion: the same algorithm, the same hand-drawn bars. And here's the part that made the switch survivable — the drawing layer didn't have to come along, because it had never been the part that was wrong. The hand-drawn look was [Rough.js](https://roughjs.com/) from day one, and I'd built that whole layer *inside* Motion Canvas already: the `Rough*` components, the wobble, even the rounded rectangles drawn as one continuous path so the corners can't come apart (that fight — and the trick an AI surfaced from Excalidraw's renderer to end it — is [its own story](https://vizlearn.app/blog/paintbrush-not-a-drawing-app)). roughjs was always the paintbrush; Motion Canvas and Remotion were only ever the thing wrapped around it. Swapping the wrapper left every shape exactly where it sat. What changed was every kind of *control*:

- **Scrubbing** is free. The transport bar sets a frame number; step 7 is a frame, not seven yields. Scrub, step, rewind — all just "go to frame N."
- **Input** is props, as above. The config panel and the animation are wired through plain React state.
- **Code sync** stopped being animation. In Motion Canvas the code panel was a `Code` node animated *inside* the same generator — the highlight was part of the recording. In Remotion the highlighted line is derived state: the animation calls `emitStep({ lines, phase, label })` at each boundary, and an ordinary React panel reads it and lights up. I no longer animate the explanation; the explanation is a function of where you are.

That last line is the whole move, really. Motion Canvas made me *perform* the lesson into a timeline. Remotion let the lesson *be* a function of the current frame — which is the only reason the code, the canvas, and the log can all stay pinned to the same instant while a learner drags the scrubber around.

## What I'd tell myself in November

None of this is a knock on Motion Canvas. It's a video studio and a really good one; if I were making a linear explainer to export and post, I might still reach for it, because generators beat frame math and the ffmpeg export is right there. I chose it because it's good. I left it because "good at making a video" and "good at being a live instrument inside a React app" are different jobs, and I'd quietly assumed they were one.

That's the same lesson VizLearn kept teaching me, one altitude at a time. A layer down, I'd grabbed the Excalidraw *editor* when what I needed was a drawing *primitive*, and dropped down to roughjs. Here I grabbed a video *studio* when what I needed was a *component*, and dropped down to Remotion. Same mistake twice: I matched the tool to the **output** I wanted (hand-drawn frames) instead of to the **boundary** I was building at (a thing the learner reaches into and drives). The note I keep for myself now is simple: match the boundary. The output tends to take care of itself.

And roughjs, the actual paintbrush under all of it, never moved an inch through any of it.
