A scroll-driven narrative landing page where a 300-frame flight sequence — cabin window, cloud layer, then the Eiffel Tower — plays behind the content, controlled entirely by the scrollbar rather than a play button.
The raw material was a short flight clip exported as 300 sequential JPGs at 1920×1080. The narrative runs in one continuous move: an aircraft cabin window, out through a dense cloud layer, and finally the Eiffel Tower standing clear above the deck at night.
The obvious implementation is a looping background video. That was rejected early. A video plays on its own clock, which means the story runs whether or not the reader is there for it — and on a marketing page the reader is the one setting the pace. Tying the sequence to scroll position makes the reveal a consequence of reading rather than something happening alongside it.
This case study documents design and engineering reasoning. It does not claim user research that was not conducted. To make it portfolio-complete, add:
| Constraint | Consequence for the design |
|---|---|
| 300 discrete frames, ~10 MB total | Everything must be resident before it can be scrubbed. Rules out lazy-loading mid-sequence and forces a decision about what to show while loading. |
| Scroll is the only transport control | No play, pause, or seek. The scrollbar becomes the timeline, so its length is a design parameter, not an accident of content height. |
| Source clip ends mid-cloud | The last frame is a tight, busy composition. The intended resolve — a wide, calm shot — is a separate still that does not cut cleanly from it. |
| Content sections supplied as a fixed 1472 px layout | Needed to become fluid without redrawing the design, and needed to stop painting over the sequence behind it. |
| Page must survive JS failure | Any reveal or hide behaviour has to fail open — visible content, degraded motion — never a blank page. |
The sequence is painted into a single <canvas> sized to the
viewport, redrawn as the frame index changes. The alternative — 300 stacked
<img> tags toggling visibility — puts 300 nodes in the layout and
compositing tree and asks the browser to do work proportional to the sequence length
on every frame.
Cost per rendered frame becomes constant instead of scaling with sequence length, which is what keeps the scrub responsive on a long sequence.
Scroll events arrive irregularly — a trackpad flick delivers a burst, a mouse wheel delivers discrete jumps. Binding frame index directly to scroll position inherits that irregularity and the sequence stutters in step with the input device.
Instead the scroll handler only records a target. A
requestAnimationFrame loop eases the rendered position
toward it at 12% per frame, and snaps when the gap becomes imperceptible.
Playback gets its own motion curve rather than inheriting the input device's. The sequence glides to a stop instead of halting the instant fingers leave the trackpad.
The first implementation returned early when the requested frame hadn't decoded yet. Scrolling quickly while the tail of the preload was still in flight left the canvas stuck on an early cloud frame — the tower reveal, the entire point of the page, simply never painted.
The renderer now falls back to the nearest frame that has decoded, searching outward from the requested index, and swaps in the exact frame the moment it lands.
Degradation became temporal precision rather than a dead screen. A slightly stale frame reads as normal playback; a frozen one reads as a broken page.
The sequence's last frame is a close, turbulent cloudscape. The intended payoff is a wide, still, star-lit shot of the tower. Appending the still as "frame 301" produces a hard cut — two unrelated compositions colliding at the exact moment the page is meant to feel resolved.
The final 20% of the scroll instead holds the last frame and dissolves into the still on a smoothstep curve, driven by the same eased progress value as the sequence itself.
Sharing one progress value means the dissolve inherits the identical inertia as the frames. Two separately-tuned curves would read as two different animations stitched together.
The sequence initially completed inside a tall hero, then handed off to conventional black sections. That split the page into "the animated bit" and "the rest".
Mapping progress to the full document instead — and making every section shell transparent — means the flight continues behind the content the entire way down. The tower resolves as the reader reaches the footer.
The reveal becomes the reward for reading the whole page, which aligns the motion with the commercial intent instead of front-loading it.
The supplied layout pinned the headline 280 px from the top of a 900 px frame — which landed it directly on the cabin window's brightest edge. Centring the block optically and breaking the headline into three tapering lines (363 / 227 / 145 px) sits it inside the window's dark aperture.
On short viewports the type scales down rather than the block repositioning, so it stays centred instead of drifting into the two pinned cards.
The service cards needed the brand blue without putting body copy on a mid-tone. The gradient runs top-to-bottom — saturated blue across the top third where only the category chip sits, resolving to near-black under the heading and paragraph.
Contrast was measured across the entire band the copy occupies (50–94% of card height), not sampled at one convenient point: 17.5:1 at worst, against a 7:1 AAA threshold.
A diagonal gradient matching the testimonial panel would have left saturated blue in one bottom corner — decorative consistency at the cost of the thing the card exists to do.
Scroll position normalises to 0…1 across the document. That single eased
value derives the frame index, the dissolve opacity, and nothing else — so every moving
part on the page shares one clock.
Content animates in as it enters the viewport — headline lines in sequence, then subtext, CTA, and cards, 90 ms apart within each group. Two implementation traps were worth recording because both produce bugs that look like design problems:
Transition direction. Declaring the transition on the hidden state means elements animate into hiding when the class is applied after first paint — a visible fade-to-blank on load. The transition belongs on the settled state, so hiding is instant and only the reveal animates.
Specificity. .rv.in and .rv.ln carry equal
weight, so source order decides. With the settled rule written first, headline lines
would have stayed permanently offset.
| Condition | Behaviour |
|---|---|
| Reduced-motion preference | Reveal system exits before marking anything. All content renders in its settled state; no element is ever hidden. |
| JavaScript fails or is blocked | Hiding classes are applied by script, never authored into the markup — so a script failure leaves the page fully readable rather than blank. |
| No IntersectionObserver | Explicit guard reveals every element immediately instead of stranding content off-screen. |
| Frames still downloading | Nearest decoded frame is drawn; the exact frame swaps in on arrival. |
| Body copy over gradient | Measured 17.5:1 minimum across the full band the text occupies — AAA with margin. |
Reduced-motion currently disables the reveals, but the scroll sequence itself still plays. For a user who set that preference because motion causes discomfort, a full-viewport flight sequence is the larger problem. The honest fix is to hold a single static frame — the resolved tower — and skip the scrub entirely. That is the first thing I would ship next.
Hold the resolved tower and bypass the sequence entirely, rather than only quieting the content reveals.
~10 MB of JPEGs is the page's dominant cost. WebP or AVIF, plus a lower-resolution set for small viewports, is the obvious first pass.
At 8.4 px per frame the scrub is fast. More page length — or fewer, better-chosen frames — would give the reveal more room to breathe.
The whole design rests on the belief that a scroll-controlled reveal reads as intentional. That needs testing against the possibility it reads as slow loading.
No performance, engagement, or conversion data exists for this build. Add measured results here once live — scroll depth, time on page, completion rate of the sequence, and whether the reveal correlates with any downstream action.