Command Palette

Search for a command to run...

Hectal
PHASE 10Advanced ~29 min· topic 6 of 6

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: auto or scroll (or the page itself).
Snap point
A position where scrolling is allowed to come to rest, set by scroll-snap-align on a child.
mandatory and proximity
mandatory always rests on a snap point; proximity snaps 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-behavior controls 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.

style.csswhole filecss
.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.

style.csswhole filecss
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.

style.csswhole filecss
.section h2 {
  position: sticky;
  top: 0;
  background: white; /* so content doesn't show through */
}
Stack section headers with stickydiagram
Rendering diagram…

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.

style.csswhole filecss
@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.

style.csswhole filecss
@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. 1

    Center the slides

    In the carousel, change the slides to flex: 0 0 160px and scroll-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. 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 move position: sticky to the div.

  3. 3

    Change the range

    In the scroll-driven example, change animation-range to cover 0% cover 50%, then to contain. Predict when each card finishes growing in each case, then scroll to check.

Code & diagrams

A snapping carousel New tab

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

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

snap type: x mandatory
asked for 130, rests at: 200
Sticky section headers New tab

Scroll 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

Press Run to see the page.

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 0
Scroll-driven progress bar and reveal New tab

Scroll 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

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

scroll-driven animations supported: true

Break 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.

terminal
$ the page shows
── what you'll see ──
The header scrolls away like a normal element and never sticks.

Break #2

animation-timeline before the shorthand

Write animation-timeline: scroll(); and then animation: grow linear; in the same rule.

terminal
$ the page shows
── what you'll see ──
The progress bar plays once over 0 seconds (it appears full at once) and ignores scrolling.

Break #3

Mandatory snap with tall content

Use scroll-snap-type: y mandatory on a page where one section is taller than the screen.

terminal
$ the page shows
── what you'll see ──
Users can't read the middle of the tall section: scrolling keeps jumping to its start or to the next section.

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 scroll listeners lag a frame and stutter when the main thread is busy. Scroll-driven CSS animations of transform/opacity are updated by the compositor in the same frame as the scroll.

  • ▸

    A scroll-driven animation is an ordinary CSSAnimation whose timeline is a ScrollTimeline or ViewTimeline; you can create them in JavaScript too: el.animate(keyframes, { timeline: new ScrollTimeline({ source: scroller }) }). Its currentTime is 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 scrollsnapchange and scrollsnapchanging events when the snap target changes; the more widely available scrollend event fires when any scroll finishes. Check support before relying on either.

  • ▸

    scroll-padding, scroll-margin and scroll-snap also affect element.scrollIntoView() and focus-driven scrolling, so setting scroll-padding-top for a sticky header fixes keyboard users tabbing to elements hidden under it, an accessibility win.

Remember this

  1. 1

    A scroll container is any element whose content can scroll: one with overflow: auto or scroll (or the page itself). Scroll snap has two halves. On the container, scroll-snap-type picks the axis (x, y, block, inline or both) and the strictness: mandatory (it must always rest on a snap point) or proximity (it snaps only when it stops close to one). On the children, scroll-snap-align: start | center | end sets which edge of the child lines up with the container. scroll-snap-stop: always stops a fast flick skipping past an item.

  2. 2

    Two helpers adjust where things settle: scroll-padding on the container shrinks the area used for snapping (for example scroll-padding-top: 60px so items aren't hidden under a 60px sticky header; it also applies to jumps to #anchor links), and scroll-margin on a child adds space around that child's snap area. scroll-behavior: smooth animates jumps from anchor links and scrollTo() calls, and overscroll-behavior: contain stops 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. 3

    position: sticky (Topic 6.2) acts like relative until the element would scroll past a threshold such as top: 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, left or right), and when an ancestor between it and the scroller has overflow: hidden, auto or scroll (that ancestor becomes the scroll container, even if it never scrolls). overflow: clip doesn't create a scroll container, so it's safe.

  4. 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, root or self) and the axis (block, inline, x, y): scroll(root block) follows the page's vertical scroll.

  5. 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-range picks part of that journey: entry (while it comes in), exit, contain (while it's fully visible) or cover (the whole journey), for example animation-range: entry 0% entry 100% to fade an element in exactly as it scrolls into view. Named timelines (scroll-timeline-name: --list on the scroller, animation-timeline: --list on any descendant) let an element follow a scroller that isn't its nearest one.

  6. 6

    Practical rules for scroll-driven animations. Write animation-timeline after the animation shorthand, because the shorthand resets it to auto. The duration is ignored (use auto or any value; the shorthand still needs a name), and use linear easing so progress matches the scroll. They run on the compositor when they animate transform or opacity, 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 for prefers-reduced-motion: reduce when they create large movement.

Explain it without notes

01

Which properties make a scroll container snap, and what is the difference between mandatory and proximity?

02

Why might position: sticky not work, and how do you debug it?

03

What is a scroll-driven animation, and how do scroll() and view() timelines differ?

04

Why are scroll-driven CSS animations smoother than effects built on the scroll event?

Practice

01

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.

02

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.

03

Add a reading-progress bar that follows the whole page's scroll, only in browsers that support scroll-driven animations.

Trade-offs

  • ↔

    mandatory snapping gives a polished carousel but can trap users in long content; proximity is 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-type and scroll-snap-align.

  • I can use scroll-padding, scroll-margin and overscroll-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() and animation-range, behind @supports.