Topic 10.6
Scroll Effects
In one line
scroll-snap makes a scroll container settle on chosen items, position: sticky keeps headers in view while their section scrolls, and scroll-driven animations (animation-timeline: scroll() and view()) tie an animation's progress to the scroll position instead of to time.
Think of it like this
A train stopping at stations. A train doesn't stop halfway between platforms; it slides into the next station and stops exactly at the platform. Scroll snapping does that for a carousel: you flick, and it settles on a card instead of stopping between two. A sticky header is like the name sign on a carriage window: it stays in view while you walk along that carriage, then the next carriage's sign takes over. A scroll-driven animation is like a progress map on the wall that moves forward only as the train moves, not as the clock ticks.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Scroll container
- An element whose content can scroll, because it has
overflow: autoorscroll(or the page itself). - Snap point
- A position where scrolling is allowed to come to rest, set by
scroll-snap-alignon a child. - mandatory and proximity
mandatoryalways rests on a snap point;proximitysnaps only when scrolling stops near one.- `scroll-padding`
- Space inside a scroll container that snapping and anchor jumps keep clear, such as room for a sticky header.
- Scroll chaining
- When an inner box reaches its end and the scroll carries on to the page.
overscroll-behaviorcontrols it. - Sticky threshold
- The
top(or other side) value where a sticky element stops scrolling and holds. - Scroll timeline
- A timeline whose progress is the scroll position of a container, from top (0%) to bottom (100%).
- View timeline
- A timeline whose progress is one element's journey through the visible area, from entering to leaving.
- `animation-range`
- The part of a view timeline an animation uses, like
entry(while the element comes into view).
Step by step
01Build a snapping carousel
Lay the slides out in a row with Flexbox, let the row scroll sideways, then add snap rules: the container chooses the axis and strictness, each slide chooses its alignment.
.carousel {
display: flex;
gap: 12px;
overflow-x: auto;
scroll-snap-type: x mandatory;
overscroll-behavior-x: contain;
}
.slide {
flex: 0 0 80%;
scroll-snap-align: center;
}02Keep snapped items clear of a sticky header
On a vertically snapping page with a 56px fixed header, each section would snap under the header. scroll-padding-top moves the snap line down.
html {
scroll-snap-type: y proximity;
scroll-padding-top: 56px; /* also fixes #anchor jumps */
}
section { scroll-snap-align: start; }03Stack section headers with sticky
Each header sticks at the top while its own section scrolls past, and the next section pushes it away. No JavaScript.
.section h2 {
position: sticky;
top: 0;
background: white; /* so content doesn't show through */
}04A reading-progress bar from the scroll
A normal keyframe animation that scales a bar from 0 to 1, but driven by the page's scroll position instead of time. transform-origin: left makes it grow from the left.
@keyframes grow { from { transform: scaleX(0); } }
.progress {
position: fixed;
top: 0; left: 0; right: 0;
height: 4px;
background: royalblue;
transform-origin: left;
animation: grow linear;
animation-timeline: scroll(root block); /* after the shorthand */
}05Reveal items as they enter
A view timeline follows each card's own journey. With animation-range: entry, the animation is complete exactly when the card is fully inside the viewport. Guard it with @supports and reduced motion.
@keyframes reveal { from { opacity: 0; translate: 0 40px; } }
@supports (animation-timeline: view()) {
@media (prefers-reduced-motion: no-preference) {
.card {
animation: reveal linear both;
animation-timeline: view();
animation-range: entry 0% entry 100%;
}
}
}Try it yourself
- 1
Center the slides
In the carousel, change the slides to
flex: 0 0 160pxandscroll-snap-align: center. Predict where slide 1 rests now (it can't center, because there's no room to its left, so it rests at the start) and what the script logs. - 2
Break and fix sticky
In the sticky example, wrap each
<h2>in a<div>. Predict what happens (the header stops sticking almost at once, because its parent is now the tiny div). Then moveposition: stickyto the div. - 3
Change the range
In the scroll-driven example, change
animation-rangetocover 0% cover 50%, then tocontain. Predict when each card finishes growing in each case, then scroll to check.
Code & diagrams
Swipe or shift-scroll the carousel: it always settles on a whole slide. Even a script that scrolls to 130px is snapped to the nearest snap point, the start of slide 2 at 200px. Change `mandatory` to `proximity` and the script's 130 stays where it is, because it isn't close enough to a snap point.
Preview
Console
Nothing logged yet.Expected console output
snap type: x mandatory
asked for 130, rests at: 200Scroll the list. Each letter sticks at the top while its section passes, then the next section pushes it up. Reading positions forces a layout, so the values are exact straight after setting `scrollTop`.
Preview
Console
Nothing logged yet.Expected console output
scrolled 60px: header A is at 0
inside section B: header A is at a negative offset (pushed away)
inside section B: header B is at 0Scroll inside the box: the blue bar fills as you go and each card grows in as it enters. In a browser without support, `@supports` skips the block: the bar stays full and the cards stay still, which is a fine fallback. The console line depends on your browser (it is `true` in current Chrome, Edge and Safari).
Preview
Console
Nothing logged yet.Expected console output
scroll-driven animations supported: trueBreak it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Sticky inside overflow: hidden
Give the page wrapper overflow: hidden (often added to stop sideways scrolling), with a sticky header inside it.
Break #2
animation-timeline before the shorthand
Write animation-timeline: scroll(); and then animation: grow linear; in the same rule.
Break #3
Mandatory snap with tall content
Use scroll-snap-type: y mandatory on a page where one section is taller than the screen.
Myth vs fact
Myth
Scroll snapping needs JavaScript.
Fact
It's two CSS properties: scroll-snap-type on the container and scroll-snap-align on the children. It works with touch, mouse wheel, keyboard and scripts.
Myth
position: sticky sticks to the viewport.
Fact
It sticks within its nearest scroll container, and only while its parent box is still visible. That's why it fails inside overflow: hidden and why section headers get pushed away.
Myth
Scroll-driven animations need a scroll event listener.
Fact
With animation-timeline, the browser drives the animation from the scroll position itself, often on the compositor, with no JavaScript and no scroll events.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
Scroll-event JavaScript runs on the main thread after the compositor has already scrolled, so effects driven by
scrolllisteners lag a frame and stutter when the main thread is busy. Scroll-driven CSS animations oftransform/opacityare updated by the compositor in the same frame as the scroll. - ▸
A scroll-driven animation is an ordinary
CSSAnimationwhosetimelineis aScrollTimelineorViewTimeline; you can create them in JavaScript too:el.animate(keyframes, { timeline: new ScrollTimeline({ source: scroller }) }). ItscurrentTimeis a percentage (CSSUnitValue) rather than milliseconds. - ▸
Snap positions are recalculated after layout changes (re-snapping), so a carousel stays aligned when the window resizes or slides are added. Chromium also fires
scrollsnapchangeandscrollsnapchangingevents when the snap target changes; the more widely availablescrollendevent fires when any scroll finishes. Check support before relying on either. - ▸
scroll-padding,scroll-marginandscroll-snapalso affectelement.scrollIntoView()and focus-driven scrolling, so settingscroll-padding-topfor a sticky header fixes keyboard users tabbing to elements hidden under it, an accessibility win.
Remember this
- 1
A scroll container is any element whose content can scroll: one with
overflow: autoorscroll(or the page itself). Scroll snap has two halves. On the container,scroll-snap-typepicks the axis (x,y,block,inlineorboth) and the strictness:mandatory(it must always rest on a snap point) orproximity(it snaps only when it stops close to one). On the children,scroll-snap-align: start | center | endsets which edge of the child lines up with the container.scroll-snap-stop: alwaysstops a fast flick skipping past an item. - 2
Two helpers adjust where things settle:
scroll-paddingon the container shrinks the area used for snapping (for examplescroll-padding-top: 60pxso items aren't hidden under a 60px sticky header; it also applies to jumps to#anchorlinks), andscroll-marginon a child adds space around that child's snap area.scroll-behavior: smoothanimates jumps from anchor links andscrollTo()calls, andoverscroll-behavior: containstops a scroll that reaches the end of an inner box from scrolling the whole page ("scroll chaining"). Snapping also applies after programmatic scrolling and after the container is resized. - 3
position: sticky(Topic 6.2) acts likerelativeuntil the element would scroll past a threshold such astop: 0, then holds it there, but only while its parent is still on screen: when the parent's bottom edge arrives, the sticky element is pushed away with it. That's what makes stacked section headers work: each section's header sticks, then is pushed off by the next section. Sticky fails silently without a threshold (top,bottom,leftorright), and when an ancestor between it and the scroller hasoverflow: hidden,autoorscroll(that ancestor becomes the scroll container, even if it never scrolls).overflow: clipdoesn't create a scroll container, so it's safe. - 4
A scroll-driven animation uses the scroll position as its clock. Normally an animation's progress goes from 0% to 100% over its duration. With
animation-timeline: scroll(), progress goes from 0% at the top of the scroll container to 100% at the bottom: perfect for a reading-progress bar.scroll()takes the scroller (nearest,rootorself) and the axis (block,inline,x,y):scroll(root block)follows the page's vertical scroll. - 5
With
animation-timeline: view(), progress follows one element's journey through the visible area of its scroller: 0% when it starts to enter, 100% when it has completely left.animation-rangepicks part of that journey:entry(while it comes in),exit,contain(while it's fully visible) orcover(the whole journey), for exampleanimation-range: entry 0% entry 100%to fade an element in exactly as it scrolls into view. Named timelines (scroll-timeline-name: --liston the scroller,animation-timeline: --liston any descendant) let an element follow a scroller that isn't its nearest one. - 6
Practical rules for scroll-driven animations. Write
animation-timelineafter theanimationshorthand, because the shorthand resets it toauto. The duration is ignored (useautoor any value; the shorthand still needs a name), and uselineareasing so progress matches the scroll. They run on the compositor when they animatetransformoropacity, so they're smoother than scroll-event JavaScript. Support: Chrome and Edge since version 115 (2023) and Safari since version 26 (2025); Firefox has them behind a setting at the time of writing. Wrap them in@supports (animation-timeline: scroll())so other browsers keep a static page, and switch them off forprefers-reduced-motion: reducewhen they create large movement.
Explain it without notes
Which properties make a scroll container snap, and what is the difference between mandatory and proximity?
Why might position: sticky not work, and how do you debug it?
What is a scroll-driven animation, and how do scroll() and view() timelines differ?
Why are scroll-driven CSS animations smoother than effects built on the scroll event?
Practice
Make a vertical list .feed (height 300px) whose .post children each fill the height and snap to their start, never skipping a post on a fast flick.
Write the CSS for a page whose <header> is 64px tall and fixed, so that jumping to #pricing doesn't hide the section heading under the header.
Add a reading-progress bar that follows the whole page's scroll, only in browsers that support scroll-driven animations.
Trade-offs
- ↔
mandatorysnapping gives a polished carousel but can trap users in long content;proximityis kinder for whole pages. - ↔
Scroll-driven animations are smooth and need no JavaScript, but aren't in every browser yet; design the page to work without them and add them with
@supports. - ↔
Scroll-linked motion is engaging but can be uncomfortable; keep it subtle and remove large movement for reduced-motion users.
Done when you can
I can build a snapping carousel with
scroll-snap-typeandscroll-snap-align.I can use
scroll-padding,scroll-marginandoverscroll-behavior.I can build stacked sticky headers and fix sticky when it fails.
I can write a scroll progress bar with
animation-timeline: scroll().I can reveal elements with
view()andanimation-range, behind@supports.