On our homepage, the section that shows our products pins in place and slides sideways while you keep scrolling down. For years that effect meant a scroll listener, a library, and a fight with janky frames. Ours is CSS. There is no JavaScript involved in the motion at all.
It uses scroll-driven animations, which let an ordinary CSS animation be driven by scroll position instead of by time. Here is the whole technique, including the parts that tripped us up.
The structure
Three elements do the work:
<section class="reel" style="--n:5">
<div class="reel-pin">
<div class="reel-track"> ...panels... </div>
</div>
</section>
The outer section is tall. Its height is what gives the visitor something to scroll through. The pin is sticky, so it stays on screen for that whole distance. The track is as wide as its panels and gets moved sideways.
The CSS
.reel {
height: calc(100svh + var(--n) * 85svh);
view-timeline: --reel block;
}
.reel-pin {
position: sticky;
top: 3.6rem;
height: calc(100svh - 3.6rem);
overflow: clip;
}
.reel-track {
display: flex;
width: max-content;
animation: reel linear both;
animation-timeline: --reel;
animation-range: contain 0% contain 100%;
}
@keyframes reel {
to { transform: translateX(calc(-100% + 100vw)); }
}
view-timeline on the section creates a named timeline that tracks the section's progress through the viewport. The track then uses that timeline instead of a clock.
The line that matters most is animation-range: contain 0% contain 100%. For an element taller than the screen, the contain range begins when its top edge reaches the top of the viewport and ends when its bottom edge reaches the bottom. That is exactly the period during which the sticky child is pinned, so the sideways movement starts and stops in step with the pin.
The keyframe moves the track left by its own width minus one screen. Percentages in translateX refer to the element itself, which is why width: max-content is important.
The bug that cost us an hour
Our pin was a grid container, and the heading above the track was centered with auto margins. It rendered far off to the right.
The cause: the track is thousands of pixels wide, and a grid's implicit column grows to fit its widest item. The heading was being centered in a column 3,357 pixels wide. One line fixed it:
.reel-pin { grid-template-columns: minmax(0, 1fr); }
minmax(0, 1fr) lets the column be narrower than its content. If you put a very wide element inside a grid or flex container, check what it did to its siblings.
Sizing panels by height, not only width
A pinned section has a fixed amount of vertical room: one screen, minus the header. If panels are sized only by width, they overflow on short laptop screens.
We size them by whichever limit is smaller:
.panel { width: min(84vw, 74svh, 62rem); }
Because the screenshots have a fixed aspect ratio, capping the width by viewport height also caps the height. The unit svh is the small viewport height, which stays stable on phones when the address bar slides away.
A progress bar for free
The same timeline can drive anything else inside the section. Our progress bar is one more animation on the same name:
.reel-bar i {
transform-origin: 0 50%;
animation: grow linear both;
animation-timeline: --reel;
animation-range: contain 0% contain 100%;
}
@keyframes grow { from { transform: scaleX(0); } to { transform: scaleX(1); } }
Numbers that count up, also without script
Below the reel, four figures count up as they scroll into view. That is CSS too. You cannot animate text, but you can animate a registered custom property and print it with a counter:
@property --num { syntax: '<integer>'; inherits: false; initial-value: 0; }
.count b {
counter-reset: num var(--num);
animation: count linear both;
animation-timeline: view();
animation-range: entry 20% cover 42%;
}
.count b::after { content: counter(num); }
@keyframes count { from { --num: 0; } to { --num: var(--to); } }
Registering --num as an integer is what makes it animatable. Each figure sets its own --to inline. The real number is also in the markup, hidden only when this technique is active, so browsers without support and search engines both see it.
Fallbacks
Everything above sits inside two guards:
@supports (animation-timeline: view()) {
@media (prefers-reduced-motion: no-preference) and (min-height: 520px) {
/* pinned version */
}
}
Outside those guards, the section is an ordinary horizontal strip with scroll snapping. It is swipeable, it works everywhere, and nothing is lost except the pinning. Visitors who prefer reduced motion get that version on purpose.
Why bother doing it in CSS
Scroll-driven animations run off the main thread. A scroll listener in JavaScript competes with everything else the page is doing, and that is where the stutter comes from. The CSS version stays smooth on a busy page, costs no bytes of script, and cannot throw an error.
We reach for JavaScript when the behavior is real logic. Motion tied to scroll position is no longer one of those cases. If your site has an effect like this that stutters, we are happy to take a look.