Chapter headline roller
Scroll crosses a chapter line and the headline rolls over: old lines climb out of a clip window while the new ones rise in behind them, one row lagging the next.
How it works
- One clip window per line, not one around the block. A single window makes the two lines a rigid pair, and the stagger — the second row arriving 90ms after the first — is the entire reason this reads as type rather than as a moving rectangle.
- 🩸 The type size has to sit on the row, because the window height is
1.06em. Leave the row at the inherited 16px and the window comes out 17px tall while the text inside it is 54px: you see a horizontal sliver of the headline, andtranslateY(-110%)is no longer far enough to clear the window either. Nothing errors. I shipped exactly this and only caught it by measuring the window against the text. - Every chapter's line exists as its own element, stacked. All three states come from one rule —
pastistranslateY(-110%),nowis 0, the untouched default istranslateY(110%)— so scrolling back up plays the reverse for free. Rewriting the text of a single element leaves nothing to cross with: the outgoing line has to still be on screen while the new one arrives. - The headline is deliberately not scrubbed. The picture behind it is continuous with scroll; the type is discrete states with a CSS transition. Type dragged frame-by-frame by the scrollbar is unreadable, and a scrubbed live region makes a screen reader announce every intermediate string.
- Hysteresis on the chapter boundary. Resting exactly on an edge — which trackpad inertia does constantly — otherwise flips the headline back and forth on sub-pixel deltas. The new chapter is committed only once progress is more than 0.04 past the edge that was crossed.
- Only the settled chapter is
aria-hidden="false". All four chapters are in the DOM at once; without this a screen reader reads the whole set as one run-on sentence. - Backdrops are one layer per chapter cross-faded over 700ms, matched to the 620ms roll so the room changes with the words rather than after them.
Numbers
- Scroll distance
- 440% of the viewport
- Chapters
- 4
- Roll
- 620ms cubic-bezier(.22,.61,.36,1)
- Row stagger
- 90ms on the second line
- Travel
- ±110% of the line box
- Boundary dead zone
- 0.04 of total progress
- Backdrop cross-fade
- 700ms
Prompt
Paste this into any coding model to get the effect from scratch. It states every number explicitly — vague words like “smooth” or “modern” produce a different result every time.
Build a scroll-driven chapter headline roller in vanilla HTML/CSS/JS. A scroll container with a 440%-tall track and a sticky stage (height: 100vh, never 100%). Four chapters, each with an eyebrow and a two-line headline. Give EACH line its own overflow: hidden window — never one window around both lines — and put the font-size and line-height on that window element itself with height: 1.06em, so the clip box matches the text; leaving the row at the inherited font size silently produces a 17px window around 54px text. Render every chapter's line as its own absolutely positioned span inside its window and drive them from one class: past = translateY(-110%) opacity 0, now = translateY(0) opacity 1, default = translateY(110%) opacity 0, transitioning transform 620ms cubic-bezier(.22,.61,.36,1) and opacity 420ms linear, with transition-delay 90ms on the second line only. Do NOT scrub the type with scroll progress — pick a discrete chapter index and let CSS transition between states. Compute chapter = min(n-1, floor(p * n)) but commit it only when |p - boundary| > 0.04 so resting on a boundary does not flicker. Set aria-hidden true on every chapter except the settled one. Cross-fade one full-bleed background layer per chapter over 700ms. Register the scroll listener passive.Source
The whole file. It is what the sample above is running — copy it into an .html file and it works with nothing else.
<!doctype html>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Chapter headline roller</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { height: 100%; }
body { font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", sans-serif; }
.scroller { height: 100%; overflow-y: auto; background: #06060c; scrollbar-width: thin; }
.track { height: 440%; }
/* 🩸 100vh,不是 100%(`.track` 高 440%,见 scroll-scrubbed-sequence 的同一条注释)。 */
.stage { position: sticky; top: 0; height: 100vh; overflow: hidden; background: #06060c; }
/* One backdrop per chapter, cross-faded. */
.bg { position: absolute; inset: 0; opacity: 0; transition: opacity 700ms cubic-bezier(.22,.61,.36,1); }
.bg.on { opacity: 1; }
.b0 { background: radial-gradient(85% 75% at 28% 18%, #1d3a8f, #070a18 72%); }
.b1 { background: radial-gradient(85% 75% at 72% 22%, #7a2f6a, #100713 72%); }
.b2 { background: radial-gradient(85% 75% at 46% 24%, #1c6f6a, #04120f 72%); }
.b3 { background: radial-gradient(85% 75% at 62% 30%, #8a5a1c, #140c05 72%); }
.grain { position: absolute; inset: 0; background: linear-gradient(180deg, #00000000 45%, #000000b8); }
.type { position: absolute; left: 6%; right: 6%; bottom: 11%; color: #fff; }
/* Eyebrow rolls in its own window, same machinery, tighter. */
/* 🩸 The type size lives on the ROW, not on the span. `height: 1.06em` on the
row resolves against the row's own font-size — leave that at the inherited
16px and the clip window comes out 17px tall while the text inside it is
54px, so you see a sliver of the headline and no error anywhere. Same trap
makes translateY(-110%) too short to clear the window. */
.eyebrow {
position: relative; overflow: hidden; margin-bottom: 10px;
font-size: 10px; line-height: 1.5em; height: 1.5em;
}
.eyebrow span {
position: absolute; left: 0; top: 0; white-space: nowrap;
letter-spacing: 0.24em; text-transform: uppercase; color: #ffffff8a;
}
/* 🩸 One clip window PER LINE, not one around the block. A single window
around both lines makes them a rigid pair — the second line can no longer
lag behind the first, and the stagger is the whole reason this reads as
type rather than as a moving rectangle. */
.line {
position: relative; overflow: hidden;
font-size: clamp(26px, 5.6vw, 54px); line-height: 1.06em; height: 1.06em;
}
.line + .line { margin-top: 0.04em; }
.line span {
position: absolute; left: 0; top: 0; white-space: nowrap;
font-weight: 500; letter-spacing: -0.035em;
}
/* Discrete states, driven by a CSS transition — deliberately NOT scrubbed.
Type dragged frame-by-frame with the scrollbar is unreadable, and it makes
a screen reader announce every intermediate string. */
.roll {
transform: translateY(110%); opacity: 0;
transition: transform 620ms cubic-bezier(.22,.61,.36,1), opacity 420ms linear;
}
.roll.past { transform: translateY(-110%); opacity: 0; }
.roll.now { transform: translateY(0); opacity: 1; }
.l2 .roll { transition-delay: 90ms; } /* the stagger */
.eyebrow .roll { transition-duration: 460ms; transition-delay: 0ms; }
.dots { position: absolute; right: 6%; bottom: 12%; display: flex; gap: 7px; }
.dots i { width: 22px; height: 2px; background: #ffffff33; border-radius: 2px; transition: background 400ms; }
.dots i.on { background: #fff; }
.cue {
position: absolute; left: 6%; top: 8%;
font-size: 11px; letter-spacing: 0.18em; text-transform: uppercase; color: #ffffff55;
}
</style>
<div class="scroller" id="scroller">
<div class="track">
<div class="stage" id="stage">
<div class="bg b0"></div><div class="bg b1"></div><div class="bg b2"></div><div class="bg b3"></div>
<div class="grain"></div>
<div class="cue">scroll ↓</div>
<div class="type">
<div class="eyebrow" id="eyebrow"></div>
<div class="line l1" id="l1"></div>
<div class="line l2" id="l2"></div>
</div>
<div class="dots" id="dots"></div>
</div>
</div>
</div>
<script>
const CHAPTERS = [
["01 — Frame", "Start From", "The Problem"],
["02 — Build", "Ship It In", "Small Steps"],
["03 — Test", "Put It In", "Real Hands"],
["04 — Keep", "Keep What", "Holds Up"],
];
const N = CHAPTERS.length;
const HYST = 0.04; // dead zone around a boundary, as a fraction of progress
const scroller = document.getElementById("scroller");
const rows = [document.getElementById("eyebrow"), document.getElementById("l1"), document.getElementById("l2")];
const dots = document.getElementById("dots");
// One element per chapter per row, all stacked. Rewriting the text of a single
// element instead would leave nothing to cross with: the old line has to still
// be on screen, on its way out, while the new one arrives.
const cells = rows.map((row, r) => CHAPTERS.map((c) => {
const el = document.createElement("span");
el.className = "roll";
el.textContent = c[r];
row.appendChild(el);
return el;
}));
CHAPTERS.forEach(() => dots.appendChild(document.createElement("i")));
const pips = [...dots.children];
const bgs = [...document.querySelectorAll(".bg")];
let chapter = -1;
const show = (i) => {
if (i === chapter) return;
chapter = i;
cells.forEach((row) => row.forEach((el, j) => {
el.className = "roll" + (j === i ? " now" : j < i ? " past" : "");
// Only the settled chapter is readable text; the rest are decoration on
// their way in or out, and a screen reader should not read all four.
el.setAttribute("aria-hidden", j === i ? "false" : "true");
}));
pips.forEach((p, j) => p.classList.toggle("on", j === i));
bgs.forEach((b, j) => b.classList.toggle("on", j === i));
};
const update = () => {
const max = scroller.scrollHeight - scroller.clientHeight;
const p = max > 0 ? Math.min(1, Math.max(0, scroller.scrollTop / max)) : 0;
const next = Math.min(N - 1, Math.floor(p * N));
if (next === chapter) return;
// 🩸 Hysteresis. Resting exactly on a boundary — which a trackpad's inertia
// does constantly — otherwise flips the headline back and forth on sub-pixel
// scroll deltas. Commit only once we are clearly past the edge we crossed.
const edge = Math.max(next, chapter) / N;
if (chapter < 0 || Math.abs(p - edge) > HYST) show(next);
};
scroller.addEventListener("scroll", update, { passive: true });
addEventListener("resize", update);
update();
</script>Where it breaks
overflow: hiddenon the line windows clips descenders and any accent that sits above the cap line —Ç,Å, a Vietnamese double diacritic. 1.06em is tight enough to cut them in most faces. Check the effect in every language the page ships before trusting the number.- Four chapters that only advance on scroll means the last three are unreachable by keyboard alone unless the page still scrolls normally. Do not add
preventDefaultto the wheel handler to "smooth" it; that is what breaks Home, End, Page Down and find-in-page. - Under
prefers-reduced-motion: reducethe roll should become a cross-fade, not disappear. Removing the transition entirely makes the headline change instantly with no relationship to the scroll, which reads as a glitch rather than as calm.
Seen in
@派大鑫 (Douyin) · “这是我 Vibe Coding 的个人站” · 0:06