← All work

Case study // 13

Avengers: Doomsday

A concept teaser that hands the projector to the scrollbar.

Role
Designer and engineer
Year
2026
Category
Design
Status
Live
Visit live Read the source
  • Next.js 16
  • React 19
  • Tailwind CSS v4
  • Framer Motion
  • React Three Fiber
  • Lenis
  • TypeScript

// CONTEXT

The problem

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

What made it hard

  • One page, no backend, no CMS. Everything the piece does has to be paid for on the visitor’s device.
  • A frame sequence has no tolerance for a missing frame. A video that drops one is a stutter; a scrub that lands on an undecoded frame is a hole in the middle of the shot.
  • Two identities, Stark red and Latverian green, have to coexist on one page without maintaining two sets of components.
  • Fan work, so it has to be unambiguous about what it is and carry no commercial framing.

// DECISIONS

What I chose, and what I didn't

  1. 01

    Derive the frame index from the section’s own getBoundingClientRect() rather than from window.scrollY.

    Why

    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.

    Instead of

    Mapping absolute scroll offsets to frames, which encodes the current page layout into the animation and desynchronises the moment a section above changes height.

  2. 02

    Decode every frame into an HTMLImageElement array before the hero becomes interactive, and show a boot readout while it happens.

    Why

    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.

    Instead of

    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.

  3. 03

    Throttle the scroll handler through requestAnimationFrame and repaint only when the computed frame index actually changes.

    Why

    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.

    Instead of

    Drawing on every scroll event, which repaints a 2880×1800 canvas several times per displayed frame to produce identical output.

  4. 04

    Declare the cursor colours with @property and a <color> syntax, and rebind --accent on a wrapper around the Iron Man sections.

    Why

    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.

    Instead of

    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

What came of it

  • Two 169-frame sequences, 338 frames in total, scrubbing against scroll with no gaps, driven by one normalised progress value per section.
  • 3,063 lines of TypeScript across 27 files, the heaviest single component being the custom HUD scrollbar at 448.
  • An accent handover that interpolates rather than cuts, with no duplicated components behind it.
  • Two production defects found and fixed while deploying it: share cards whose metadataBase still pointed at localhost, and a grain overlay loaded from a third-party demo host that had started returning 404, so the effect had been silently absent in production.

// REFLECTION

What I'd do differently

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.