Rapido, in motion.
A 30-second showreel, plus a 30-second film for each part of Rapido. All five are drawn live in your browser: every frame is computed from a single number, the time. Watch them below, download the MP4s, then see how they were built.
Watch it live
Five films, ready to use.
The same code, rendered offline. Every frame is averaged from 8 to 32 sub-frames for real motion blur, then encoded to H.264 at 1920 × 1080, 60 fps. No audio; they're built to play muted. Use them anywhere a video goes: a deck, LinkedIn, a product page or a trade-show screen.
One function of time.
There is no timeline editor and no keyframe file. The whole reel is one JavaScript function,
render(t), that draws a complete 1920 × 1080 frame for any moment
t between 0 and 30 seconds. Nothing carries over from the previous frame.
That one constraint does most of the work. Scrubbing, looping, slow motion and the offline
MP4 export all fall out of it for free: to get frame 437, call render(437 / 60).
Randomness is seeded by index, so a particle's path is the same in every render.
- Films
- 5 · 2:30
- Frames
- 9,000 @ 60 fps
- Canvas
- 1920 × 1080
- Sub-frames
- 8–32 per frame
- Shared engine
- motion.js
- Dependencies
- 0
Every scene, every exit.
Each scene owns one idea and one way out. Pick a film, then click a scene to jump the player to it.
Springs, not keyframes.
Anything that lands, like letters, cards, chips or the mark itself, lands on a damped spring. It overshoots, settles, and feels physical. Anything that travels, like camera moves and wipes, uses exponential easing, so it leaves fast and arrives soft. Staggers of 20 to 90ms turn one motion into a wave.
Seven impacts drive a decaying camera shake. A flash frame and a shockwave that brightens the dot grid as it passes sell each hit.
// Damped spring, 0 → 1. dt = seconds since release.
const spring = (dt, freq = 2, damping = 6.5) =>
dt <= 0 ? 0 : 1 - Math.exp(-damping * dt) * Math.cos(2 * Math.PI * freq * dt);
// "Accredo," — each letter released 45ms after the last.
glyphs.forEach((g, i) => {
const s = spring(t - 2.16 - i * 0.045, 1.5, 6.5);
ctx.translate(g.x + g.w / 2, baseline + (1 - s) * 300);
ctx.rotate((1 - s) * 0.5); // pivots from the baseline
ctx.fillText(g.ch, -g.w / 2, 0);
}); Holds without dead frames.
The first cut ran 15 seconds and never stopped to breathe. The 30-second cut keeps every transition at exactly the same speed and spends the extra time on holds, so the message lands and the cuts still hit hard.
Each scene is authored on its own clock, and a piecewise-linear map decides how output time flows through it: full speed through a transition, slowed through a scan, nearly frozen on a finished order. Ambient motion (orbits, data packets, pulses, grain) runs on real time, so a hold never looks like a paused video.
// Output time → the scene's authored clock.
// Slope 1 = as authored, < 1 = slowed, ~0 = a hold.
const mapB = pw([
[12.30, 6.90], // PO2Order enters
[13.00, 7.60], // scan starts
[15.36, 8.90], // scan at 0.55× so every line reads
[15.71, 9.25], // stamp, full speed
[16.75, 9.45], // hold on the finished order
[17.20, 9.90], // slice transition, full speed
...
]);
S4(ctx, mapB(t)); // choreography: remapped
dotGrid(ctx, RT); // ambient motion: real time Transitions are brand shapes.
No crossfades. Every cut is made from something already on screen, and the mark gets the first and last word.
Zoom-through
The mark becomes the door into the headline.
Match cut
A full stop becomes a circle becomes a background.
Card expand
A UI node grows into the scene it names.
Button press
A click on Reorder grows into the order it creates.
Slice
Nine bands, alternating, staggered 25ms apart.
Whip pan
inOutExpo over 0.32s. Motion blur does the rest.
Bar wipe
An underline stretches into the next colour.
Triangle iris
The mark closes the loop. Brand shapes are the edit.
Motion blur by supersampling time.
Canvas has no motion blur, but a pure function of time doesn't need it. For each output frame the exporter draws eight sub-frames spread across half a frame (a 180° shutter, like a film camera) and averages them. Fast moves smear, slow ones stay sharp. During the ten transitions, where the camera can cross a full frame-width in a few frames, that goes up to 32 sub-frames so the blur reads as a smear, not a stack of copies.
Grain, the key light and the vignette are drawn once, after the average, so they stay crisp. The showreel HUD (timecode and scene counter) is only on the live player; the MP4 is clean so it can go straight into a deck. Turn on Motion blur above to see a lighter version live.
// Motion blur = averaging sub-frames across a 180° shutter.
for (let s = 0; s < samples; s++) {
const ts = t + ((s + 0.5) / samples - 0.5) * (0.5 / fps);
drawScene(layer, ts);
ctx.globalAlpha = 1 / (s + 1); // running average
ctx.drawImage(layer.canvas, 0, 0);
} From canvas to MP4.
- Build the site. Astro self-hosts Inter and JetBrains Mono, so the export uses exactly the fonts this page uses.
- Open the page headless. Playwright drives Chromium to this URL and waits for the fonts.
- Render every frame. A 1920 × 1080 canvas renders each frame from 8 sub-frames (32 in transitions) and returns it as a lossless PNG.
- Encode. The PNGs stream straight into ffmpeg's stdin: libx264, CRF 20,
+faststartso it plays before it finishes downloading.
npm run build
node scripts/render-showreel.mjs --reel portal
# → public/animation/rapido-portal.mp4 (H.264, CRF 20, faststart)
# → public/animation/rapido-portal-poster.jpg
# Review stills without re-rendering (reels load live from public/):
node scripts/render-showreel.mjs --reel portal --stills 3 4.25 12 Only brand tokens.
No colour or typeface appears in the reel that isn't already on this site: six tokens from
global.css, Inter for display, JetBrains Mono for the HUD and data.