I redid my logo recently. It’s a house broadcasting wifi with an RJ45 jack for a front door and a price tag hanging off the roof, and it’s an SVG, a dozen vector paths in a text file. When I wanted an animated intro for YouTube, the obvious route was After Effects or Motion Canvas or some editor I’d have to learn for six seconds of video. But the logo was already in the browser’s native art format. Browsers are extremely good at animating vectors. So the intro is a web page.
The pipeline
Three pieces: an HTML file with the animation, Playwright to screenshot it frame by frame, and ffmpeg to stitch the frames into video. No screen recording anywhere, which matters more than it sounds.
Strokes that draw themselves
The house and wifi arcs “draw on” using the oldest SVG trick there is. Set pathLength="1000" on the path, set stroke-dasharray: 1000, then animate stroke-dashoffset from 1000 to 0. The stroke chases itself around the path like a pen. That’s the entire house-drawing effect, one animated property.
Link lights and a swinging tag
The RJ45 door pops in with a scale ease, then its eight pins light left to right, eight tiny animations with start times staggered 70ms apart. If you’ve watched a switch come up after a power blip, you know the effect I was going for.
The price tag drop is two animations stacked. The outer group falls in with a translateY. The inner group swings with rotation keyframes that overshoot and settle: 24°, then -12°, then 5°, then -2°, then 0. No physics engine, just keyframes shaped like damping.
The part that makes it work: a scrubbable timeline
Normally web animations play in real time, and if you screen-record them you inherit every dropped frame and stutter. Instead, every animation is created paused, and one function seeks all of them at once:
const anims = [];
function A(el, kf, opts) {
const a = el.animate(kf, {fill: 'both', ...opts});
a.pause();
anims.push(a);
return a;
}
window.seek = t => anims.forEach(a => a.currentTime = t);
The page stops being a video and becomes a scrubber. Ask for t=2400 and you get exactly the frame at 2.4 seconds, every time, regardless of how slow the machine is.
Screenshot, 378 times
A Playwright loop steps through the timeline at 60fps and screenshots the stage div at each step:
for (let i = 0; i < frames; i++) {
await page.evaluate(`seek(${i * 1000 / 60})`);
await page.locator('#stage').screenshot({path: `frames/f${String(i).padStart(4,'0')}.png`});
}
6.3 seconds at 60fps is 378 PNGs. Takes about a minute.
ffmpeg does the rest
ffmpeg -framerate 60 -i f%04d.png -c:v libx264 -pix_fmt yuv420p -crf 18 -movflags +faststart intro.mp4
Flat vector art compresses absurdly well. The finished 1080p60 file is about 180KB, smaller than most thumbnails.
The bug that ate my wordmark
One thing wasted twenty minutes so it doesn’t waste yours. I’d added a second animation on the channel name to hold it invisible before its reveal: keyframes opacity: 0 to opacity: 0, running from t=0 until the wipe started. With fill: 'both', an animation holds its last keyframe forever after it ends. Last keyframe: opacity 0. The name faded in and was simultaneously held at zero for the rest of time, so the final frame of my intro was a logo next to a lonely slash. The fix was deleting the hack entirely, because the clip-path wipe already hides the text before its start time when it fills backwards.
Why this beats a video editor for me
The animation is code, so it takes parameters. The page reads ?speed=1.5&mode=mark from the URL: speed stretches every delay and duration, and mark mode hides the wordmark and renders just the logo, centered, for bumpers. When I decided the first render felt rushed, the slower version was a re-render, not an edit. When the logo changes someday, the intro regenerates in a few minutes.
There’s no sound yet. I’m adding that in the video editor where the intro gets used, which is also where it belongs.