I wanted coding videos that people who don't code could enjoy too. Most "speed coding" videos are a sped-up screen recording: lots of scrolling, a terminal, then a website appears at the end. Non-developers have no idea what they are looking at.
So I wrote a small pipeline that turns a git repository into a video. On the right, an editor types out each commit. On the left, the real site runs, and every element appears the moment the line that creates it is typed. A camera follows whatever just appeared. At the end, the editor slides away and the finished site is used like a visitor would: switching languages, clicking menu tabs, filling in a booking form.
Here is the first result, a café website (Next.js 16, Tailwind CSS 4, three languages, three colour themes):
This post is about how it works.
The idea in one paragraph
A video is a list of frames. Each frame has three parts: the editor (which characters of which file are visible), the site (which elements are visible, and where it is scrolled to), and some chrome (captions, a progress bar). If I can compute all three for every frame from the git history alone, I never have to screen-record anything, and the output is perfectly smooth and repeatable.
The steps are the commits. Each commit subject becomes the caption on screen ("Hero: headline, intro and two calls to action"), so the history has to be written for viewers: one visible section per commit.
1. Typing a commit
For every file a commit changes, I diff the old and new versions line by line (a plain LCS table is fine for files this size) and turn the diff into a stream of editor states:
// One state per video frame: which lines are on screen, and where the caret is.
function* typeEdit(fromText, toText, charsPerFrame) {
const ops = diffLines(fromText.split('\n'), toText.split('\n'));
// 'eq' ‚Üí the line is already there
// 'del' ‚Üí old lines disappear, one per frame
// 'add' ‚Üí new lines are typed, a few characters per frame
...
yield { rows, cursor: [row, col], at: [lineInNewFile, col] };
}
Two details made the typing look right:
-
Syntax colours come from the finished file, never the half-typed one. If you highlight
className="flexwhile the quote is still open, the whole rest of the file turns string-coloured for a few frames and flickers. Instead, I tokenize the complete new file once with Shiki and only cut each line's tokens at the caret. - Long data files are "pasted". A menu with 40 items typed character by character is boring. Blocks over 30 lines type their first lines and then the rest scrolls in quickly.
2. Making the site grow with the code
This is the part I'm happiest with. The naive approach is to screenshot the site after each commit and cross-fade between screenshots. It works, but the site jumps: a whole section appears at once, long after its code was typed.
Instead, for each commit I find every new JSX element with the TypeScript compiler API and tag it:
import ts from 'typescript';
const sf = ts.createSourceFile(file, newText, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
const visit = (node) => {
const tag = ts.isJsxElement(node) ? node.openingElement.tagName
: ts.isJsxSelfClosingElement(node) ? node.tagName : null;
if (tag && isOnAddedLine(node)) {
const id = `${step}-${marks.length}`;
marks.push({ id, at: endOfFirstLine(node) }); // when the caret passes this, show it
inserts.push({ pos: tag.end, text: ` data-g="${id}"` });
}
ts.forEachChild(node, visit);
};
visit(sf);
The tagged version of the file is written into the repo, the dev server picks it up, and in the browser a single CSS rule keeps every tagged element out of the layout:
[data-g]:not([data-on]) { display: none !important; }
While the editor types, every frame checks the caret position. When it passes the end of an element's first line, that element gets data-on and fades in. So the logo appears when <Logo /> is typed, the menu items appear when their .map(...) line is typed, and the booking form fields appear one by one.
A couple of things I learned here:
- Lines that only change (a new class on an existing element) shouldn't be tagged, or the element would vanish until it's retyped. I compare each added line with the lines it replaced in the same hunk.
- Every commit must render on its own, because the video checks each one out separately. That turned out to be a nice discipline for the code too.
3. A camera that follows the build
The site runs in an iframe at 1440√ó900 (plus a 390√ó844 one in a phone frame). Each frame, a small script inside the iframe eases the scroll position toward the newest visible element:
const r = g.focus.getBoundingClientRect();
const top = r.top + g.y, bottom = r.bottom + g.y;
if (bottom > g.y + vh - 60) target = Math.min(top - 90, bottom - vh + 60);
if (top < g.y + 70) target = top - 90;
g.y += (target - g.y) * 0.14; // glide, don't jump
window.scrollTo(0, Math.round(g.y));
New elements fade in over 12 frames with a small upward slide. Together with the glide, the page feels like it's being assembled in front of you.
4. The ending: using the site like a visitor
A video of code being typed is only half the story; people want to see the result work. Each project has a small JSON script for the ending:
[
{ "caption": "Menu with category tabs", "scroll": "#menu" },
{ "click": "[role=\"tab\"]", "index": 1, "in": "both", "hold": 24 },
{ "caption": "Table booking straight to WhatsApp", "scroll": "#booking" },
{ "fill": "input[name=\"name\"]", "text": "Anna" },
{ "select": "select[name=\"guests\"]", "value": "4" },
{ "highlight": "#booking form button", "hold": 26 }
]
fill types one character per frame and sets values through the native setter plus an input event, so React state follows. Language switches reload the iframe behind a still of the previous frame and cross-fade, which hides the reload.
5. Rendering
Everything is drawn in one Puppeteer page: the editor is HTML, the browsers are iframes, and each frame is a JPEG screenshot. ffmpeg turns the frames into a 30 fps video, and a short Python script lays the music under it (looped with a cross-fade, loudness-normalised, faded in and out). The same pipeline renders a vertical 1080√ó1920 version for Shorts by swapping the layout.
A 2-minute video is about 3,500 frames and renders in a few minutes on a laptop.
What I'd tell you if you try this
- Write your history for the viewer. One visible section per commit, short commit subjects, one JSX element per line.
- Don't screenshot; drive the real page. Hiding and revealing real DOM nodes looks far better than cross-fading images.
- Keep it deterministic. No timers in the page, no "wait until it looks done": every frame is computed, so a re-render gives the same video.
I'm making these for a channel, Codelapse: a café, a barber shop with a booking widget, and a SaaS landing page with a dashboard drawn in pure CSS are done so far. I wrote the pipeline and the sites with the help of Claude Code.
What should I build next? And if you'd like the pipeline itself as an open-source tool, let me know in the comments.
Top comments (0)