All posts

We Considered Hijacking Your Scroll and Decided Not To

Scroll-jacking buys sequencing at the cost of every affordance for leaving. How we built a scroll-driven hero with sticky instead, and the bugs on the way.

Our homepage opens on a line drawing that pans left to right as you scroll, through five stations: a cab, a curb, a concourse, a gate, a window seat. Beside it a conversation accumulates, one message at a time, as though someone were building an agent during the journey.

To build it we had to decide whether to take your scroll wheel away from you. We decided not to, and the reasoning is worth writing down, because the tempting version is easier to build and slightly better looking.

What hijacking would have bought

The effect wants precise sequencing. Station three should arrive as the third message lands. If you own the wheel events you get that exactly: intercept the input, advance an internal counter, animate to the next state, ignore anything that arrives mid-transition. Every visitor sees the same choreography at the same pace.

It also breaks everything else. When you capture the scroll, the scrollbar stops describing the document. End stops working. Page Down stops working. Find-in-page can locate text and then cannot travel to it. Dragging the scrollbar thumb does nothing, or something arbitrary. On a trackpad, momentum from a hard flick either gets discarded or fires ten transitions at once.

All of those are how a visitor leaves. The sequencing is worth something. It is not worth every affordance a person has for getting out of your hero.

What we did instead

The scroller is a genuinely tall element. The stage inside it is position: sticky. That is the whole mechanism.

The page really is that long, so the scrollbar reports real progress, because it is real progress. Every key still works, because we never touched the keys. The camera reads a single progress value and eases between stations. We compute where to look; the browser decides when.

The difference in code is small. The difference in what a visitor can do is total.

The bug that came from writing the breakpoint twice

Sticky has a floor. Below a certain viewport height the pin stops working, and not cosmetically: a sticky element taller than its scrollport can never bring its own bottom into view. The conversation and the chapter rail would sit permanently below the fold for the entire journey, unreachable by any input.

So there is a static fallback, and a threshold that selects it. We got the threshold wrong twice.

The first mistake was setting it from a desktop reading of "enough room". That sent an iPhone 13 to the static layout. A phone reports the height left under the browser chrome, not the height of the screen, so hardware with plenty of room was being unpinned by a number chosen while looking at a laptop.

The second was structural and worse. The condition lived in two languages: a media query in CSS, and the same breakpoint restated in TypeScript for the scroll driver. Nothing can keep those in step. A portrait phone duly fell into the static layout while the scroll driver still believed it was pinned, and the two halves of the hero disagreed about what page they were on.

The fix was to give the condition one owner. The stylesheet publishes what its media queries concluded as custom properties, and the driver reads them back:

.stage { --hero-pinned: 1; --hero-frame: wide; }

A media condition cannot read a custom property, so the arrow only points one way, which is the point. There is now a single place where the composition is decided. A missing flag reads as unpinned, so the failure mode shows the whole conversation rather than one line of it.

Ninety pixels of hard cut

Messages arrive as you travel. The first version toggled display and animated an eight pixel slide on the new bubble.

It looked wrong and it took a while to see why. Toggling display moved roughly ninety pixels of layout in a single frame. The reveal animated eight of them. Nine tenths of the movement was an instantaneous cut that no easing curve could soften, because easing was never applied to it. We had been tuning the timing function on the visible tenth.

Each message now stays in the layout permanently and transitions its grid row from 0fr to 1fr. Nothing appears; things grow. The transcript follows the growing height across the duration rather than jumping to its end value, and the duration is read from --motion-duration-reveal so the stylesheet remains its only owner.

interpolate-size would say this more directly and is still Chromium only. The padding on a bubble sits on an inner element, because a growing grid row warps padding applied to the row itself.

Small decisions that only look small

The heading sits on a band of the near-black ground at eighty percent rather than a flat fill. At that strength the skyline behind it lands near invisible and only the brightest strokes register, so the scene reads through faintly instead of being cut out of a rectangle. It carries no backdrop blur: the canvas behind it repaints on every scrolled frame, and blurring that region each time is a cost the effect does not repay.

The last message used to land at 0.996 of the pinned scroll, roughly the final ten pixels, because each beat was anchored to its station and whatever fell outside got clipped. Beats now take equal slots on a track of their own and the closing line lands at 0.95, read well before the pin releases.

The chapter rail is gone on narrow screens. It cost eighty pixels of the only band the drawing had left, to jump between five stations a thumb reaches by scrolling anyway.

The composer bar under the transcript is deliberately not an input. No focus, hidden from assistive technology. Nothing should offer to accept a message it cannot send.

The rule underneath all of it

Every one of these went the same way. Something looked slightly better and cost a visitor something they could not see us taking.

Scroll hijacking costs the exit. Toggling display costs the illusion of motion. A blurred band costs frame budget on a canvas that repaints anyway. A breakpoint written twice costs the guarantee that the page agrees with itself. An input that is not an input costs a promise you cannot keep.

The hero is oblique on purpose and a stranger reaching the bottom of it still cannot name the product. That is what the four sentences underneath are for. What the hero has to do is hold attention without taking anything hostage to get it.