CSS Module 16 – Transitions, animations, scroll and view transitions
Scroll snap and scroll-driven animations
Carousels that snap with scroll-snap-type and scroll-padding, animations driven by scroll() and view() timelines, and fallbacks where they do not run.
What you will learn
- Build a swipeable carousel with scroll-snap-type, scroll-snap-align and scroll-padding, and predict where it comes to rest
- Link an animation to scroll position with scroll() and view() timelines and animation-range
- Write fallbacks so pages work fully in browsers without scroll-driven animations
- Use the CSS carousel pseudo-elements, scroll buttons and scroll markers, only as an enhancement over real buttons
Before you start
What you’ll learn · Before you start
What you will learn
- Build a swipeable carousel with scroll-snap-type, scroll-snap-align and scroll-padding, and predict where it comes to rest
- Link an animation to scroll position with scroll() and view() timelines and animation-range
- Write fallbacks so pages work fully in browsers without scroll-driven animations
- Use the CSS carousel pseudo-elements, scroll buttons and scroll markers, only as an enhancement over real buttons
Before you start
On this page
Scroll snap makes a scroll container come to rest at positions you choose instead of anywhere: one card of a
carousel, one photo of a gallery, one section of a story. You set scroll-snap-type on the container and
scroll-snap-align on its children, and every swipe, scroll-wheel turn and script call then lands on a child.
Scroll-driven animations tie an animation’s progress to scrolling instead of to the clock: with
animation-timeline: scroll() a reading-progress bar fills as the page scrolls, and with view() a card fades in as
it enters the window, then plays back as you scroll up. Scroll snap is Baseline widely available. Scroll-driven
animations have limited availability: Chrome 115 and Safari 26 support them, and Firefox does not yet, so they belong
behind @supports with a page that is complete without them. Snapping and scroll timelines are measured here in
three engines (Chromium 153, Firefox 155, WebKit 26.6), down to the scroll position where each effect starts and
ends.
Scroll snap: the container and its items
Snapping needs two declarations and usually three more:
scroll-snap-typeon the scroll container names the axis,x,y,block,inlineorboth, and how strict to be.mandatoryalways comes to rest on a snap position;proximity, the default, snaps only when the scroll ends close to one.scroll-snap-alignon each child says which of its edges to line up:start,centerorend.scroll-paddingon the container shrinks the area that snapped items line up with, the snapport, for example to keep them clear of a sticky header or to leave a gap at the edge.scroll-marginon a child grows its own snap area instead.scroll-snap-stop: alwaysstops a fast swipe at the next item instead of letting it fly past several.
/* A horizontal carousel: each card snaps to the start, 20px in from the edge, and one swipe moves one card. */
.carousel {
display: flex;
gap: 16px;
overflow-x: auto;
padding-inline: 20px;
scroll-padding-inline: 20px; /* the snapport starts 20px in, so a snapped card lines up with the padding */
scroll-snap-type: x mandatory; /* always come to rest on a card */
overscroll-behavior-x: contain; /* a swipe past the last card does not go back in the browser's history */
}
.carousel > .card {
flex: 0 0 280px;
scroll-snap-align: start;
scroll-snap-stop: always; /* a fast swipe stops at the next card instead of flying past several */
}
@media (prefers-reduced-motion: no-preference) {
.carousel {
scroll-behavior: smooth; /* the previous and next buttons glide; nobody else's scrolling changes */
}
} overscroll-behavior-x: contain asks the browser not to pass a swipe that reaches either end on to the page, for
example as a back or forward navigation gesture. It has limited availability, because the Baseline data lists no
Safari support, so treat it as a hint. scroll-behavior: smooth makes the buttons glide, only for people who have
not asked for less motion.
Where the carousel comes to rest
Each card’s snap position is the scroll offset that lines its start edge up with the snapport’s start, 20px in from the edge. This program works them out from the layout, with the last one clamped to the end of the scroll range:
// Where the carousel's cards snap, worked out from its layout the way CSS Scroll Snap describes it: a card's snap
// area is its border box, the snapport is the scroller minus scroll-padding, and with scroll-snap-align: start a snap
// position lines the two start edges up. Positions past the end of the scroll range are clamped to it, and a scroll
// comes to rest on the snap position nearest to where it was heading.
const scroller = { width: 360, padding: 20, scrollPadding: 20 };
const card = { width: 280, gap: 16, count: 6 };
const scrollWidth = 2 * scroller.padding + card.count * card.width + (card.count - 1) * card.gap;
const max = scrollWidth - scroller.width;
const starts = Array.from({ length: card.count }, (_, i) => scroller.padding + i * (card.width + card.gap));
const positions = starts.map((start) => Math.min(Math.max(start - scroller.scrollPadding, 0), max));
console.log(`scrollWidth ${scrollWidth}px, scroll range 0 to ${max}px`);
starts.forEach((start, i) => {
const note = start - scroller.scrollPadding > max ? ` (wanted ${start - scroller.scrollPadding}px: past the end)` : '';
console.log(`card ${i + 1} starts at ${start}px and snaps at scrollLeft ${positions[i]}px${note}`);
});
const restsAt = (target) => {
const wanted = Math.min(Math.max(target, 0), max);
return positions.reduce((best, p) => (Math.abs(p - wanted) < Math.abs(best - wanted) ? p : best));
};
for (const target of [100, 200, 1300, 1460]) console.log(`scrollTo(${target}) comes to rest at ${restsAt(target)}px`); Output
scrollWidth 1800px, scroll range 0 to 1440px card 1 starts at 20px and snaps at scrollLeft 0px card 2 starts at 316px and snaps at scrollLeft 296px card 3 starts at 612px and snaps at scrollLeft 592px card 4 starts at 908px and snaps at scrollLeft 888px card 5 starts at 1204px and snaps at scrollLeft 1184px card 6 starts at 1500px and snaps at scrollLeft 1440px (wanted 1480px: past the end) scrollTo(100) comes to rest at 0px scrollTo(200) comes to rest at 296px scrollTo(1300) comes to rest at 1184px scrollTo(1460) comes to rest at 1440px
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node snap.mjs
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
The page scrolls the real carousel by script and reads where it came to rest. Its Next button calls
scrollBy() by one card and its gap:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>A carousel that snaps, measured</title>
<link rel="stylesheet" href="carousel.css">
<link rel="stylesheet" href="carousel-extras.css">
<style>
body { margin: 0; font: 16px/24px Arial, sans-serif; }
.carousel { inline-size: 360px; box-sizing: border-box; }
.card { block-size: 120px; background: #fef3c7; }
</style>
</head>
<body>
<section>
<div class="carousel" id="carousel" tabindex="0" role="region" aria-label="Offers">
<article class="card" id="card-1">Offer 1</article>
<article class="card" id="card-2">Offer 2</article>
<article class="card" id="card-3">Offer 3</article>
<article class="card" id="card-4">Offer 4</article>
<article class="card" id="card-5">Offer 5</article>
<article class="card" id="card-6">Offer 6</article>
</div>
<div class="carousel-buttons">
<button type="button" data-step="-1">Previous offer</button>
<button type="button" data-step="1">Next offer</button>
</div>
</section>
<script>
const carousel = document.getElementById('carousel');
const step = 280 + 16; // one card and one gap
for (const button of document.querySelectorAll('[data-step]')) {
button.addEventListener('click', () => carousel.scrollBy({ left: step * Number(button.dataset.step) }));
}
// Scroll by script to a few positions, wait until it has come to rest, and read where it snapped.
const rest = () => new Promise((r) => setTimeout(r, 400));
(async () => {
carousel.style.scrollBehavior = 'auto';
console.log(`scrollWidth ${carousel.scrollWidth}px, so the furthest it can scroll is ${carousel.scrollWidth - carousel.clientWidth}px`);
for (const left of [100, 200, 1300, 1460]) {
carousel.scrollTo({ left });
await rest();
console.log(`scrollTo(${left}): came to rest at ${carousel.scrollLeft}px`);
}
carousel.scrollTo({ left: 0 });
await rest();
document.querySelector('[data-step="1"]').click();
await rest();
console.log(`the Next button from 0px: ${carousel.scrollLeft}px`);
})();
</script>
</body>
</html> | The script asked for | Came to rest at (all three engines) |
|---|---|
scrollTo(100) |
0px, the first card |
scrollTo(200) |
296px, the second card |
scrollTo(1300) |
1184px, the fifth card |
scrollTo(1460) |
1440px, the end of the range |
| Next button, from 0px | 296px |
Programmatic scrolls snap too, as the specification requires, so a script never leaves the carousel between cards.
The last card shows the edge case: lining it up at 20px would need a scroll position of 1480px, but the carousel can
only scroll 1440px, so it rests at the end with the card fully visible 60px from the edge. Give the scroller
tabindex="0", role="region" and an aria-label: the first puts it in the tab order, where the arrow keys move
it card by card, and the other two tell screen reader users what the region holds. Keep real Previous and Next
buttons as well, which work in every browser.
On a vertical page the same padding fixes a classic annoyance: a link to #reviews scrolls the heading under a
sticky header. scroll-padding-block-start on html, set to the header’s height, tells the browser that the top
of the window is covered, for snapping and for scrolling things into view; scroll-margin-block-start on a single
target adds space above just that element.
Animating with the scroll position: scroll()
A scroll-driven animation uses ordinary @keyframes, but its progress comes from a scroll progress timeline
instead of time. animation-timeline: scroll() uses the nearest scroll container’s block axis; scroll(root) the
page, and scroll(x) or scroll(inline) the other axis. At the top the animation is at 0 %, at the bottom at 100 %,
and in between it follows the scrollbar. The duration no longer matters, but the easing still applies; use linear
to keep the bar in step with the scroll.
/* A reading-progress bar that grows with the page's scroll position, only where scroll timelines exist. */
.progress {
position: fixed;
inset-block-start: 0;
inset-inline: 0;
block-size: 4px;
background: #2563eb;
transform-origin: 0 50%;
display: none; /* browsers without scroll timelines show no bar at all */
}
@keyframes grow {
from { scale: 0 1; }
to { scale: 1 1; }
}
@supports (animation-timeline: scroll()) {
.progress {
display: block;
animation: grow linear both;
animation-timeline: scroll(root block); /* progress = how far the page has scrolled, 0% to 100% */
}
}
/* A growing bar is not travel across the screen, but slide-ins, parallax and big zooms driven by scrolling are:
keep those for people who have not asked for less motion. */ <!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>A reading-progress bar driven by scrolling</title>
<link rel="stylesheet" href="progress.css">
<style>
body { margin: 0; font: 16px/24px Arial, sans-serif; }
article { block-size: 2600px; padding: 16px; }
</style>
</head>
<body>
<div class="progress" id="bar"></div>
<article>A long article: 2600px tall in a 600px window, so the page scrolls 2000px.</article>
<script>
// Scroll to 0 %, 50 % and 100 % of the way and read the bar's scale after the next frame.
const bar = document.getElementById('bar');
const frame = () => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
(async () => {
const max = document.documentElement.scrollHeight - innerHeight;
console.log(`CSS.supports('animation-timeline: scroll()') is ${CSS.supports('animation-timeline: scroll()')}; the page scrolls ${max}px`);
for (const share of [0, 0.5, 1]) {
scrollTo(0, max * share);
await frame();
const timeline = bar.getAnimations()[0]?.timeline;
const kind = timeline ? timeline.constructor.name : 'no animation';
console.log(`${share * 100} % scrolled: display ${getComputedStyle(bar).display}, scale ${getComputedStyle(bar).scale}, timeline ${kind}`);
}
})();
</script>
</body>
</html> | Scrolled | Chromium and WebKit | Firefox |
|---|---|---|
| 0 % | scale 0 1 | no bar |
| 50 % | scale 0.5 1 | no bar |
| 100 % | scale 1 | no bar |
In Chromium and WebKit, getAnimations() reported the bar’s timeline as a ScrollTimeline, and the bar’s scale was
exactly the share scrolled. Firefox failed the @supports (animation-timeline: scroll()) test, so the bar stayed
display: none. Order matters in the stylesheet: the animation shorthand resets animation-timeline and
animation-range, so declare them after it, never before.
Animating as an element passes through: view()
animation-timeline: view() follows the element itself through the scrollport, a view progress timeline. Its
full journey, the cover range, starts when its leading edge first appears and ends when its trailing edge has
left. animation-range trims the animation to part of that journey, using named ranges:
The ranges of a view() timeline, for a 200px card 1000px down the page
Text description of the diagram
The diagram follows a 200 pixel tall card whose top is 1000 pixels down the page, in a window 600 pixels tall, through four scroll positions.
- At scrollY 400 the card's top edge reaches the bottom of the window. This is 0% of the cover range and 0% of the entry range.
- At scrollY 600 its bottom edge reaches the bottom of the window, so the whole card is in view. This is 100% of entry and 0% of contain. The entry range took 200 pixels of scrolling.
- At scrollY 1000 its top edge reaches the top of the window. This is 100% of contain and 0% of exit. The contain range took 400 pixels.
- At scrollY 1200 its bottom edge reaches the top of the window, and the card is out of view. This is 100% of exit and 100% of cover. The exit range took 200 pixels, and the whole cover range 800 pixels.
entryruns while the element is coming in, from its first pixel to fully visible.containruns while it is fully visible, or, for an element taller than the scrollport, while it covers it completely.exitruns while it is leaving.coveris the whole journey, fromentry0 % toexit100 %.
entry 0% entry 100% is the usual choice for a reveal, so the card has finished arriving by the time it is fully in
view:
/* Cards that fade and rise into place as they scroll into view, for people who have not asked for less motion. */
@keyframes reveal {
from { opacity: 0; translate: 0 40px; }
to { opacity: 1; translate: 0 0; }
}
@supports (animation-timeline: view()) {
@media (prefers-reduced-motion: no-preference) {
.reveal {
animation: reveal linear both;
animation-timeline: view(); /* progress follows the card through the scrollport */
animation-range: entry 0% entry 100%; /* only while it is entering: bottom edge in, until fully in view */
}
}
} <!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>A card revealed by a view() timeline</title>
<link rel="stylesheet" href="reveal.css">
<style>
body { margin: 0; }
.spacer { block-size: 1000px; }
.reveal { block-size: 200px; background: #dcfce7; }
.after { block-size: 1400px; }
</style>
</head>
<body>
<div class="spacer"></div>
<div class="reveal" id="card">A card whose top is 1000px down the page</div>
<div class="after"></div>
<script>
// In a 600px window the card's top meets the bottom edge at scrollY 400 and its bottom does at scrollY 600:
// that is the entry range. Read the card's opacity at a few scroll positions.
const card = document.getElementById('card');
const frame = () => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
(async () => {
const out = [];
for (const y of [300, 400, 450, 500, 550, 600, 900]) {
scrollTo(0, y);
await frame();
out.push(`${y}px → ${Number(getComputedStyle(card).opacity).toFixed(2)}`);
}
console.log(`supports view(): ${CSS.supports('animation-timeline: view()')}; opacity at scrollY ${out.join(', ')}`);
})();
</script>
</body>
</html> | scrollY | Chromium and WebKit | Firefox |
|---|---|---|
| 300 | 0.00 | 1.00 |
| 400 | 0.00 | 1.00 |
| 450 | 0.25 | 1.00 |
| 500 | 0.50 | 1.00 |
| 550 | 0.75 | 1.00 |
| 600 | 1.00 | 1.00 |
| 900 | 1.00 | 1.00 |
The card’s top is 1000px down the page and the window is 600px tall, so the entry range runs from scrollY 400, where
its top reaches the bottom edge, to 600, where its bottom does; the measured opacity rose in a straight line in
between. Firefox showed the card at full opacity throughout, and that is the design rule for every scroll-driven
reveal: the element’s own style is its finished state, and only the animation hides it, so a browser without
scroll timelines, or a person who prefers reduced motion, gets a normal page. Under Playwright’s reduced-motion
emulation, Chromium showed the card at opacity 1.00 at every position. Never hide content with opacity: 0 in the
base style and rely on the animation to show it.
Named timelines connect elements that are not ancestor and descendant: view-timeline-name: --card on the card and
animation-timeline: --card on a caption elsewhere, with timeline-scope: --card on a shared ancestor to make the
name visible to the caption. Chromium and WebKit accepted all three properties; Firefox accepted none.
CSS carousel extras: scroll buttons and scroll markers
Chromium 135 and later can generate a carousel’s controls from CSS. ::scroll-button(inline-start) and
::scroll-button(inline-end) create real, focusable buttons that scroll by about one container’s width and disable
themselves at either end, and ::scroll-marker gives every card a dot inside a ::scroll-marker-group, with
:target-current matching the dot of the card in view. Both have limited availability, so this stylesheet adds them
only inside @supports selector(::scroll-button(*)), and hides the page’s own buttons only there:
/* Chromium only: previous and next buttons and one dot per card, generated by CSS. Browsers without them skip this
whole block and keep the page's own buttons, so nobody loses a way to move through the cards. */
@supports selector(::scroll-button(*)) {
.carousel::scroll-button(inline-start) {
content: "←" / "Previous offer"; /* the text after the slash is the button's accessible name */
}
.carousel::scroll-button(inline-end) {
content: "→" / "Next offer";
}
.carousel::scroll-button(*):disabled {
opacity: 0.4; /* at either end the generated button disables itself */
}
.carousel {
scroll-marker-group: after; /* a row of dots after the cards */
}
.carousel > .card::scroll-marker {
content: "";
inline-size: 12px;
block-size: 12px;
border-radius: 50%;
border: 2px solid #92400e;
}
.carousel > .card::scroll-marker:target-current {
background: #92400e; /* the dot of the card in view */
}
.carousel-buttons {
display: none; /* the generated buttons replace the page's own */
}
} Loaded into the same carousel page, Chromium generated the Next button with the content "→" / "Next offer", whose
text after the slash is its accessible name, and set the page’s own buttons to display: none. Firefox 155 and
WebKit 26.6 generated nothing and kept the real buttons, which is the point: every visitor can still move through the
cards.
Practise: snap positions and view ranges
Exercise · Medium · JavaScript
Work out snap positions and view() timeline progress
Write three functions in scrolling.mjs and export them. All positions are pixels along the scrolling axis.
snapPositions(scroller, items) returns the scroll positions a scroller can snap to, sorted from smallest to largest and without repeats. scroller is { width, scrollWidth, paddingStart = 0, paddingEnd = 0 }, where the paddings are its scroll-padding; each item is { start, size, align = 'start', marginStart = 0, marginEnd = 0 }, where start is the item's position inside the scroller's content and the margins are its scroll-margin.
- The snapport is the visible part minus the scroll padding: it begins paddingStart after the scroll position and is width − paddingStart − paddingEnd wide. - The item's snap area runs from start − marginStart to start + size + marginEnd. - start lines up the starts of the two, end their ends and center their centres. - A position below 0 becomes 0, and one past scrollWidth − width becomes scrollWidth − width.
restingPoint(positions, target, max) returns where a scroll heading for target comes to rest: clamp target between 0 and max, then take the nearest snap position.
viewProgress(scrollY, subject, viewport, range = 'cover') returns the progress, from 0 to 1 (or beyond, outside the range), of a view() timeline for a subject { top, height } in a scrollport viewport pixels tall, at scroll position scrollY. Find the start and end scroll positions of the range, then return (scrollY − start) ÷ (end − start):
- cover starts when the subject's top meets the bottom of the scrollport (top − viewport) and ends when its bottom meets the top (top + height). - contain runs between the two positions where an edge of the subject meets the same edge of the scrollport, top and top + height − viewport: from the smaller to the larger. - entry runs from the start of cover to the start of contain, and exit from the end of contain to the end of cover.
The sample tests import the three functions from scrolling.mjs and run in your browser. Their numbers were measured in browser engines.
Starter code · scrolling.mjs
/** The scroll positions a scroller can snap to, sorted and without repeats. */
export function snapPositions({ width, scrollWidth, paddingStart = 0, paddingEnd = 0 }, items) {
// Replace this line with your code.
return items.map((item) => item.start);
}
/** Where a scroll heading for target comes to rest. */
export function restingPoint(positions, target, max) {
// Replace this line with your code.
return target;
}
/** The progress of a view() timeline range at scroll position scrollY. */
export function viewProgress(scrollY, { top, height }, viewport, range = 'cover') {
// Replace this line with your code.
return 0;
} The sample tests · scrolling.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { snapPositions, restingPoint, viewProgress } from './scrolling.mjs';
// The lesson's carousel: 360px wide, scroll-padding 20px, six 280px cards 16px apart after a 20px padding.
const scroller = { width: 360, scrollWidth: 1800, paddingStart: 20, paddingEnd: 20 };
const cards = (extra = {}) => [0, 1, 2, 3, 4, 5].map((i) => ({ start: 20 + i * 296, size: 280, ...extra }));
const r = (v) => Math.round(v * 1000) / 1000;
test('start alignment, with the last card clamped to the end of the range', () => {
assert.deepEqual(snapPositions(scroller, cards()), [0, 296, 592, 888, 1184, 1440]);
});
test('center and end alignment', () => {
assert.deepEqual(snapPositions(scroller, cards({ align: 'center' })), [0, 276, 572, 868, 1164, 1440]);
assert.deepEqual(snapPositions(scroller, cards({ align: 'end' })), [0, 256, 552, 848, 1144, 1440]);
assert.deepEqual(snapPositions(scroller, cards({ align: 'end', marginEnd: 10 })), [0, 266, 562, 858, 1154, 1440]);
});
test('scroll-margin widens the snap area, and repeats are dropped', () => {
assert.deepEqual(snapPositions(scroller, cards({ marginStart: 10 })), [0, 286, 582, 878, 1174, 1440]);
assert.deepEqual(snapPositions({ width: 300, scrollWidth: 300 }, [{ start: 0, size: 100 }, { start: 100, size: 100 }]), [0]);
});
test('a scroll comes to rest on the nearest snap position', () => {
const positions = [0, 296, 592, 888, 1184, 1440];
assert.deepEqual([100, 200, 1300, 1460, -50].map((t) => restingPoint(positions, t, 1440)), [0, 296, 1184, 1440, 0]);
assert.equal(restingPoint([0, 276, 572, 868, 1164, 1440], 1000, 1440), 868);
assert.equal(restingPoint([0, 256, 552, 848, 1144, 1440], 1000, 1440), 1144);
assert.equal(restingPoint([0, 266, 562, 858, 1154, 1440], 1000, 1440), 858);
});
test('a 200px card at 1000px in a 600px scrollport', () => {
const card = { top: 1000, height: 200 };
assert.deepEqual([400, 450, 500, 550, 600].map((y) => r(viewProgress(y, card, 600, 'entry'))), [0, 0.25, 0.5, 0.75, 1]);
assert.equal(r(viewProgress(800, card, 600)), 0.5);
assert.equal(r(viewProgress(800, card, 600, 'contain')), 0.5);
assert.equal(r(viewProgress(1100, card, 600, 'exit')), 0.5);
assert.equal(r(viewProgress(300, card, 600, 'entry')), -0.5);
});
test('a subject taller than the scrollport', () => {
const tall = { top: 1000, height: 900 };
assert.equal(r(viewProgress(1150, tall, 600, 'contain')), 0.5);
assert.equal(r(viewProgress(700, tall, 600, 'entry')), 0.5);
assert.equal(r(viewProgress(1600, tall, 600, 'exit')), 0.5);
assert.equal(r(viewProgress(1150, tall, 600, 'cover')), 0.5);
}); A hint
For each item, work out where its snap area's start, centre or end sits, then subtract where the snapport's start, centre or end sits at scroll position 0 (paddingStart, paddingStart + snapport ÷ 2 or paddingStart + snapport). The difference is the scroll position that lines them up.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (QuickJS): nothing is sent to mysmartcopilot.com. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Check yourself
6 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.
Interview questions
Fresher · What is the difference between scroll-snap-type mandatory and proximity?
Both are set on the scroll container, with an axis, such as scroll-snap-type: x mandatory. With mandatory the
container must come to rest on a snap position whenever no scrolling is going on, after a swipe, a wheel turn, a key
press or a script call such as scrollTo(), so it is never left between two items. With proximity, the default
strictness, the browser snaps only when the scroll ends close enough to a snap position, and otherwise leaves it
where it stopped. Use mandatory for carousels and galleries where each item fills the view. Use proximity for
long pages with snap points between sections, because mandatory can make content between widely spaced snap
positions impossible to rest on, which the specification warns about for items taller than the screen.
Key takeaways
scroll-snap-typeon the container andscroll-snap-alignon its children make it come to rest on a child;scroll-paddingshrinks the snapport andscroll-margingrows a child’s snap area.- Programmatic scrolls snap too; a snap position past the end of the range is clamped, so the last card rests at the end, as all three engines measured.
animation-timeline: scroll()maps the scroll position from 0 % to 100 % onto an animation; declare it after theanimationshorthand, which resets it.view()follows an element through the scrollport;animation-rangepicksentry,contain,exitorcover.- Scroll-driven animations run in Chromium and Safari, not in Firefox: put them behind
@supportsand reduced motion, and never hide content in the base style. - Scroll buttons and markers are Chromium-only; keep real buttons for everyone else.
References
- CSS Scroll Snap Module Level 1 (W3C CSS Working Group)
- Scroll-driven Animations (W3C CSS Working Group)
- CSS Overflow Module Level 5: scroll buttons and scroll markers (W3C CSS Working Group)
- CSS Animations Level 2: the animation shorthand (W3C CSS Working Group)
- Media Queries Level 5: prefers-reduced-motion (W3C CSS Working Group)
- Scroll-driven animation timelines (MDN Web Docs) (Mozilla)
- CSS scroll snap (MDN Web Docs) (Mozilla)
- animation-range (MDN Web Docs) (Mozilla)
- ::scroll-button() (MDN Web Docs) (Mozilla)
- ::scroll-marker (MDN Web Docs) (Mozilla)
- Web features explorer: scroll-driven animations (W3C WebDX Community Group)
- Web features explorer: scroll snap (W3C WebDX Community Group)
- Web features explorer: scroll markers (W3C WebDX Community Group)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress