Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Advanced
  • 30 minutes
  • Examples run with Node.js 24.21.0 and quickjs 0.32.0
  • By MySmartCoPilot
Free: 30 minutes

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-type on the scroll container names the axis, x, y, block, inline or both, and how strict to be. mandatory always comes to rest on a snap position; proximity, the default, snaps only when the scroll ends close to one.
  • scroll-snap-align on each child says which of its edges to line up: start, center or end.
  • scroll-padding on 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-margin on a child grows its own snap area instead.
  • scroll-snap-stop: always stops a fast swipe at the next item instead of letting it fly past several.
A carousel that snaps one card at a time CSS · carousel.css
/* 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.

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:

The carousel's snap positions, worked out JavaScript · snap.mjs
// 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

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:

The carousel, scrolled by script and measured HTML · carousel.html
<!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 driven by scroll() CSS · progress.css
/* 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. */
The bar at the top, the middle and the end of a page HTML · progress.html
<!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 entry, contain, exit and cover ranges of a view() timeline for a 200px card in a 600px window, as scroll positions.scrollY 400the card's top edge reaches the bottom of the windowcover 0% · entry 0%scrollY 600its bottom edge reaches the bottom: fully in viewentry 100% · contain 0%scrollY 1000its top edge reaches the top of the windowcontain 100% · exit 0%scrollY 1200its bottom edge reaches the top: out of viewexit 100% · cover 100%entry: 200px of scrollingcontain: 400pxexit: 200px

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.

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  • entry runs while the element is coming in, from its first pixel to fully visible.
  • contain runs while it is fully visible, or, for an element taller than the scrollport, while it covers it completely.
  • exit runs while it is leaving.
  • cover is the whole journey, from entry 0 % to exit 100 %.

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 in while they enter CSS · reveal.css
/* 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 */
    }
  }
}
The card's opacity at seven scroll positions HTML · reveal.html
<!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.

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:

Generated buttons and dots, for Chromium only CSS · carousel-extras.css
/* 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.

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.

  1. Question 1 of 6 A carousel has scroll-snap-type: x mandatory, but it still stops anywhere between its cards. What is missing?

    Choose one answer.

    Show the answer to question 1

    Answer: scroll-snap-align on the cards

    scroll-snap-type turns snapping on for the container, but the snap positions come from its children's scroll-snap-align. Without it there are no snap positions, and with none, the specification says no snapping happens. scroll-snap-stop only stops a fast swipe from passing positions that exist.

  2. Question 2 of 6 A 400px-wide scroller holds 300px cards 20px apart, the first at 0px, with scroll-snap-align: start and no scroll padding. A script calls scrollTo(170). Where does it come to rest?

    Type a number.

    Show the answer to question 2

    Answer: 320 px

    The snap positions are the cards' starts: 0px, 320px, 640px and so on. 170px is 170px from 0 and 150px from 320, so a mandatory snap container comes to rest at the nearest one, 320px. Programmatic scrolls snap just like swipes.

  3. Question 3 of 6 A card has animation-timeline: view() and animation-range: entry 0% entry 100%. When does its animation run?

    Choose one answer.

    Show the answer to question 3

    Answer: From the moment its first pixel appears until it is fully in view

    entry runs from 0 % of cover, the card's leading edge reaching the scrollport, to 0 % of contain, the card fully inside it. In the lesson's measurement that was scrollY 400 to 600 for a 200px card 1000px down the page. The whole journey is cover, and the part while fully visible is contain.

  4. Question 4 of 6 Why must animation-timeline come after the animation shorthand in a rule?

    Choose one answer.

    Show the answer to question 4

    Answer: The shorthand sets animation-timeline back to auto, the document's clock

    The animation shorthand includes the timeline and resets animation-range too, so a timeline declared before it is overwritten with auto, and the animation then runs on time instead of on scrolling. Declare the shorthand first, then animation-timeline and animation-range.

  5. Question 5 of 6 A page reveals cards with a view() timeline. Which of these keep it working in a browser without scroll-driven animations?

    Choose every answer that is right.

    Show the answer to question 5

    Answer:

    • The scroll-driven rules sit inside @supports (animation-timeline: view())
    • The cards' own style is their finished state, at full opacity

    If only the animation hides the cards, a browser that cannot run it shows them normally; Firefox did exactly that in the lesson. The @supports block keeps the animation away from browsers that would misread it. A base style of opacity: 0 is the classic bug: nothing ever reveals the cards there. Scroll markers are a separate, Chromium-only feature.

  6. Question 6 of 6 What is true of ::scroll-button() and ::scroll-marker today?

    Choose one answer.

    Show the answer to question 6

    Answer: Only Chromium generates them, so real buttons must stay for other browsers

    Both have limited availability: Chromium 135 and later generate them. In the lesson's run Chromium created the Next button with its accessible name, while Firefox and WebKit generated nothing and kept the page's own buttons, which the stylesheet hides only inside @supports selector(::scroll-button(*)).

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-type on the container and scroll-snap-align on its children make it come to rest on a child; scroll-padding shrinks the snapport and scroll-margin grows 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 the animation shorthand, which resets it.
  • view() follows an element through the scrollport; animation-range picks entry, contain, exit or cover.
  • Scroll-driven animations run in Chromium and Safari, not in Firefox: put them behind @supports and reduced motion, and never hide content in the base style.
  • Scroll buttons and markers are Chromium-only; keep real buttons for everyone else.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.