9 Scrollytelling CSS Section Patterns, Copy-Paste
Scrollytelling CSS makes scroll position the clock that drives a scene: one section, several states, advanced by how far the reader has traveled.
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 Pinned figure, stepped text
- 02 Captions over a pinned backdrop
- 03 Frame sequence scrub
- 04 Before-and-after wipe
- 05 Bars that grow on entry
- 06 Exploded parts assembly
- 07 Route map that draws itself
- 08 Chapter page turn
- 09 CTA that rises at the end
These are not sorted by how often they show up. They follow the arc a story takes. First you build the stage: pin a figure and let the prose walk past it (01), or pin the whole backdrop and send only the caption cards through (02). With the stage standing, you can run time inside it — cut one scene into eight frames and wind through them (03), or stack two scenes and push the seam across (04). Then come the numbers and the object: bars that rise into their values (05) and parts that fly apart to show what is inside (06). The last three walk the reader out — a map of where the story has been (07), a page that turns into the next chapter (08), and an ask that only appears once there is nothing left to read (09). All nine ship with an autoplay preview, and the first touch hands the wheel back to real scrolling.
01Pinned figure, stepped text
The pane splits in two, the figure on the left stays put, and the paragraphs on the right climb past it. Each new paragraph cuts the figure to its next state in a single frame, so nothing ever crossfades into a half-readable blend. The cut points are where a paragraph crosses the middle of the pane, which for a 96px paragraph in a 176px pane lands on 28% and 76%.
.sf__figure {
position: sticky;
top: 0;
align-self: start;
display: grid;
height: 104px;
}
.sf__panel {
grid-area: 1 / 1;
animation-name: sf-p1;
animation-duration: 1s;
animation-timing-function: steps(1, end);
animation-fill-mode: both;
animation-timeline: scroll(nearest block);
}
.sf__panel--2 { animation-name: sf-p2; }
@keyframes sf-p1 { 0% { opacity: 1 } 28% { opacity: 0 } 100% { opacity: 0 } }
@keyframes sf-p2 { 0% { opacity: 0 } 28% { opacity: 1 } 76% { opacity: 0 } 100% { opacity: 0 } }
02Captions over a pinned backdrop
The backdrop holds absolutely still while translucent cards rise from below and leave through the top. A single dimming sheet is wired straight to scroll progress, so the art gets darker in proportion to how much of it is covered. Across the run it repaints 43.372% of the pane, the largest share of the nine, because every card sweeps across the full backdrop.
.cb__scroller {
height: var(--h);
overflow-y: auto;
scroll-timeline-name: --cb-scroll;
scroll-timeline-axis: block;
}
.cb__bg {
position: sticky;
top: 0;
height: var(--h);
overflow: hidden;
}
.cb__dim {
position: absolute;
inset: 0;
animation-name: cb-dim;
animation-fill-mode: both;
animation-timeline: --cb-scroll;
}
@keyframes cb-dim { from { opacity: 0 } to { opacity: .46 } }
03Frame sequence scrub
Eight frames sit side by side on one strip, and pushing that strip sideways by exactly as much as you scroll reads like scrubbing a video. steps(8, jump-none) includes both ends of the range, so each of the eight lands once and no in-between offset is ever shown. The frames themselves are not animated — the lid angle is drawn from the frame index and baked in.
.sp__window {
width: var(--frame);
height: 104px;
overflow: hidden;
}
.sp__strip {
display: flex;
width: max-content;
animation-name: sp-scrub;
animation-timing-function: steps(8, jump-none);
animation-fill-mode: both;
animation-timeline: --sp-scroll;
}
@keyframes sp-scrub {
from { transform: translateX(0); }
to { transform: translateX(-87.5%); }
}
.sp__lid { transform: rotate(calc(var(--i) * -14deg)); }
04Before-and-after wipe
Two scenes occupy the same box, and trimming the top one away from the right uncovers the one beneath. The whole trim is a single value inside clip-path: inset(), and the handle line rides the same timeline so it can never drift off the seam. It is only the preview curve that runs fastest in the middle, which is what makes the shared thumbnail land on a half-wiped frame where both scenes are visible.
.bw__frame {
view-timeline-name: --bw-view;
view-timeline-axis: block;
position: relative;
overflow: hidden;
}
.bw__layer--before {
clip-path: inset(0 0 0 0);
animation-name: bw-cut;
animation-fill-mode: both;
animation-timeline: --bw-view;
}
@keyframes bw-cut {
from { clip-path: inset(0 0 0 0); }
to { clip-path: inset(0 100% 0 0); }
}
@keyframes bw-handle {
from { transform: translateX(calc(var(--fw) - 4px)); }
to { transform: translateX(0); }
}
05Bars that grow on entry
Five bars lie flat against the floor and unfold in proportion to how far the block has entered the viewport. Nothing changes height: the bars are squashed from their base and released, so no layout pass is triggered. The wave comes from giving each bar a slightly later animation-range rather than a delay, because a scroll timeline has no clock for a delay to shift.
.db__chart {
display: grid;
grid-template-columns: repeat(5, 1fr);
align-items: end;
height: 96px;
}
.db__bar {
transform: scaleY(.04);
transform-origin: bottom;
animation-name: db-grow-in;
animation-fill-mode: both;
animation-timeline: view(block);
}
.db__col:nth-child(1) .db__bar { height: 34px; animation-range: entry 8% cover 38%; }
.db__col:nth-child(5) .db__bar { height: 80px; animation-range: entry 32% cover 62%; }
@keyframes db-grow-in {
from { transform: scaleY(.04); }
to { transform: scaleY(1); }
}
06Exploded parts assembly
Four stacked plates spread outward to show what they hide, then gather back into one object. Each plate has its own destination, so the keyframes are split four ways, and the labels only fade in once there is room for them to be read. Eight animations share one timeline, which means any scroll position you stop at is a coherent exploded diagram.
.ex__part {
position: absolute;
top: 48px;
animation-duration: 1s;
animation-fill-mode: both;
animation-timeline: scroll(nearest block);
}
.ex__part--1 { animation-name: ex-p1; }
.ex__part--4 { animation-name: ex-p4; }
@keyframes ex-p1 {
from { transform: translate(0, -6px) rotate(0deg); }
to { transform: translate(-16px, -54px) rotate(-7deg); }
}
@keyframes ex-p4 {
from { transform: translate(0, 6px) rotate(0deg); }
to { transform: translate(16px, 54px) rotate(7deg); }
}
@keyframes ex-tag { 0%, 10% { opacity: 0 } 38%, 100% { opacity: 1 } }
07Route map that draws itself
A solid line draws itself over a faint dashed guide while a marker runs the same curve. Stamping pathLength="100" on the path means you never have to measure it: the dash offset simply counts down from 100 to 0. The marker takes that same curve through offset-path, which is why the line and the marker cannot fall out of step.
.rm__line {
stroke-dasharray: 100;
stroke-dashoffset: 100;
animation-name: rm-draw;
animation-fill-mode: both;
animation-timeline: scroll(nearest block);
}
@keyframes rm-draw {
from { stroke-dashoffset: 100; }
to { stroke-dashoffset: 0; }
}
.rm__mark {
offset-path: path("M20 96C62 96 58 34 100 34C142 34 138 92 180 92C206 92 214 60 228 36");
offset-distance: 0%;
offset-rotate: 0deg;
animation-name: rm-run;
animation-timeline: scroll(nearest block);
}
@keyframes rm-run { from { offset-distance: 0% } to { offset-distance: 100% } }
08Chapter page turn
A sheet turns on its left edge and uncovers the next one. The rotation has to be tied to scroll at a constant rate so that the distance traveled and the angle stay one-to-one, and the back of the sheet must be hidden or the front text shows through. Its biggest single-frame repaint is 21.014%, the largest of the nine, because one sheet covers the opposite half of the spread.
.pt__page {
position: absolute;
right: 0;
width: 120px;
transform-origin: left center;
transform-style: preserve-3d;
animation-name: pt-turn;
animation-timing-function: linear;
animation-fill-mode: both;
animation-timeline: scroll(nearest block);
}
@keyframes pt-turn {
from { transform: rotateY(0deg); }
to { transform: rotateY(-180deg); }
}
.pt__face {
position: absolute;
inset: 0;
backface-visibility: hidden;
}
.pt__face--back { transform: rotateY(180deg); }
09CTA that rises at the end
While the body is being read, the bar has no place on screen at all; it rises in step with how far the closing block has entered the viewport. That block publishes its own named view timeline, and the bar nested inside it consumes the progress, so there is not a single line of visibility-checking code. The button inside starts its animation-range at 24% and swells a beat later.
.ec__end {
display: flex;
align-items: flex-end;
height: var(--h);
view-timeline-name: --ec-end;
view-timeline-axis: block;
}
.ec__bar {
position: sticky;
bottom: $sp-2;
transform: translateY(140%);
animation-name: ec-rise;
animation-fill-mode: both;
animation-timeline: --ec-end;
animation-range: entry 0% entry 80%;
}
@keyframes ec-rise {
from { transform: translateY(140%); }
to { transform: translateY(0); }
}
Where it breaks — the one trap
The trap in this set was attaching a timeline to something other than what you meant. The "nearest scroll container" that scroll(nearest) picks counts any box with overflow: hidden. The strip in 03 lived inside the window that crops it to one frame, and the dimming sheet in 02 lived inside the box that keeps the art from spilling. Neither box ever scrolls, so the declaration parsed cleanly, the console stayed quiet, and progress sat at zero forever. view() resolves its scrollport by the same rule, which is how the top layer in 04 ended up measuring its own cropping box. All three were fixed by naming the timeline with scroll-timeline-name and view-timeline-name, since a named timeline can be picked up by any descendant of the element that declares it.
The same "where does it attach" question came back with position: sticky. Items 03, 06, 07, and 08 pin a window and leave empty space below it to scroll through, and that space started life as padding-bottom on the parent. A sticky box can only stay put within its parent's content box, so padding adds nothing to its range. The window slid straight off the top, and the autoplay preview looked perfect right up until somebody actually scrolled. Swapping the padding for a height fixed all four at once.
The third one came from the preview and the real scroll running different code paths. The bars in 05 used loop keyframes that grow and then lie back down, and feeding those same keyframes to a scroll timeline meant the chart flattened again once the reader kept going. animation-fill-mode: backwards made it worse by leaving everything past the range unfilled, so the bars snapped back to their authored state. The scroll path now uses a one-way db-grow-in with both, and the loop keeps its original keyframes. Item 09 failed from the opposite side: its bar is positioned with position: sticky, but nothing scrolls during the preview, so sticky never engaged and the bar appeared in zero of the twenty-four captured frames. It is now absolutely positioned for the duration of the preview only. None of these three surfaced until all nine were screenshotted at 0%, 50%, and 100% with the preview class stripped off.
Browser support is a condition rather than a trap. As of September 2026, scroll-driven animations run in Chrome and Edge 115 and up, Safari 26 and up, and Firefox 158 and up (caniuse puts the total at 87.22%), and anything older discards the declaration outright. A discarded declaration leaves the element in its authored state, so authoring that state as "invisible" is how content disappears on older builds. There is no JavaScript fallback here. Instead, all nine were written to read without a scroll timeline at all: 01 and 02 still lay out on position: sticky alone, and the bars in 05 keep their real heights and simply stay squashed.
Accessibility (reduced-motion)
Under prefers-reduced-motion: reduce, all nine drop the choreography and keep the outcome. What counts as the outcome differs from item to item.
| Item | Turned off | Left standing |
|---|---|---|
| 01 Pinned figure | Figure swap, paragraph scrub | First figure and all three paragraphs |
| 02 Pinned backdrop | Progressive dimming | The art and all three captions |
| 03 Frame scrub | Strip travel | The sixth frame, held |
| 04 Wipe | Seam travel | Both scenes, split in half |
| 05 Growing bars | The growth itself | Five bars at full height |
| 06 Exploded parts | Spread, label fade-in | The exploded diagram with labels |
| 07 Route map | Line draw, marker travel | A finished route with lit stops |
| 08 Page turn | The rotation | The first sheet and a half-filled rail |
| 09 End CTA | Rise, button swell | The bar already up, button at size |
Whatever sits in that second column is the static state, and each of the nine was captured separately to confirm that state still makes sense on its own. That check is what caught 05 sitting at scaleY(.04) and rendering an empty chart. The nine sources behind all of this, translations and React ports included, sit in a zip that opens with the archive password rgaqtmah.
Reading order survives too. None of the nine hijacks the scroll to force a pace, every string stays in document order, and a screen reader walks the section top to bottom regardless of what is animating. Decorative shapes and icons carry aria-hidden so they are never announced. Per-property support and the meaning of each value are documented in MDN's animation-timeline reference.
For more in this family, see 9 homepage scroll effects and 9 scroll progress indicators.
FAQ
What does this look like where scroll timelines are unsupported?
The declaration is discarded and the element renders in its authored state, which makes that authored state your fallback. The bars in 05 declare their real heights and are squashed only by transform, so even when the squash never releases, the columns and labels are where they belong. Hiding a starting state behind opacity: 0 or display: none does the opposite and erases the content entirely on an unsupported build, which is why none of the nine do that.
How do animation-timeline: scroll() and view() differ?
scroll() reads how far a scroll container has rolled from top to bottom as a value from 0 to 1. Reach for it when the whole block is one progress bar, which is what 01, 02, 03, 06, 07, and 08 do behind something pinned. view() reads how much of the element itself has entered the viewport instead, which is the right question for 04, 05, and 09, where the cue is "once this comes into sight." The view() side then uses animation-range with named phases such as entry and cover to say exactly where the window opens and closes.
Why write the preview loop and the real behavior separately?
Because a demo embedded in a grid sits in an iframe that nobody scrolls. Zero scroll means zero progress on a scroll timeline, which would leave the tile a still image. So each of the nine runs on a clock for as long as the .is-demo class is present, and drops that class the moment a pointer lands or a real scroll fires, handing control to the scroll timeline. The knack is keeping the two paths close enough that the handover does not jump.