Case study // 13
A concept teaser that hands the projector to the scrollbar.
// CONTEXT
Made for its own sake, with no client and no brief — which is the point. A trailer controls its own pacing: it decides how long to hold on the helmet before it cuts. A web page hands that control to the reader, who can stop halfway or throw the scrollbar to the end. The question was whether the grammar survives the handover, and what has to change when the projector is a scrollbar someone else is holding.
// CONSTRAINTS
// DECISIONS
Progress becomes a normalised 0→1 of how far through that section the reader is, so the sequence stays correctly pinned no matter what is added above it. The dialogue cards, the progress bar and the power readout all then read from that one number, which is why they never drift out of step with the footage.
Mapping absolute scroll offsets to frames, which encodes the current page layout into the animation and desynchronises the moment a section above changes height.
The whole value of the piece is a scrub that never gaps. Holding the interaction back until the sequence is fully decoded is the only way to guarantee that, and the readout turns the wait into part of the fiction rather than an empty screen.
Streaming frames in as they arrive, which is far kinder to the network and puts a hole in the shot the first time someone scrolls faster than the download.
Scroll fires far more often than the display refreshes, and consecutive events frequently resolve to the same frame. Guarding on the index means the canvas is only touched when there is a different picture to draw, which is what keeps a full-viewport redraw off the critical path.
Drawing on every scroll event, which repaints a 2880×1800 canvas several times per displayed frame to produce identical output.
A registered custom property is interpolatable, so the cursor transitions between the two palettes across the handover instead of snapping. Scoping the rebind to a wrapper means the same components render in either identity without taking a theme prop, so the palette stays a CSS concern and never reaches the component API.
Swapping a class and duplicating the themed components, which is two of everything to maintain and a hard cut where the piece wants a dissolve.
// OUTCOME
// REFLECTION
The preload is the honest problem. Roughly 25MB of JPEG has to land before the hero is interactive, and I chose that deliberately to protect the scrub — but I only ever watched it load on a fast connection, which is exactly the condition under which that trade-off looks free. Decoding a sparse set of keyframes first and filling in behind them would have kept the guarantee where it matters and cut the wait substantially. The second gap is that there is no prefers-reduced-motion path anywhere in a site that is almost entirely motion, including a scroll-jacked hero and a permanently animated cursor; Framer Motion already exposes useReducedMotion, and wiring it in from the first component would have cost nothing next to retrofitting it now. Two 169-frame sequences from earlier cuts sat unreferenced in public/ for the life of the project. Deleting them was the easy half; it takes 80MB out of a checkout and nothing out of the repository, because the blobs stay reachable through every earlier commit. Actually reclaiming it meant rewriting the history and force-pushing over the branch, which took a clone from 117MB to 36MB and invalidated every existing one. That is a cheap trade here, on a solo repo nobody else has cloned, and it would not have been on anything shared \u2014 which is the real lesson: committing 114MB of media is a decision that gets expensive to reverse the moment a second person clones it. What is genuinely still standing is eleven eslint errors, mostly the setState-in-effect mount guard, which I left alone rather than disturb timing they are load-bearing for.