CSS

CSS scroll-snap: Build a CSS Carousel Without JavaScript (2026 Guide)

W
W3Tweaks Team
Frontend Tutorials
Jun 4, 2026 25 min read
CSS scroll-snap: Build a CSS Carousel Without JavaScript (2026 Guide)
Build a CSS carousel without JavaScript in 5 lines of CSS. This complete 2026 guide covers every scroll-snap property, mandatory vs proximity, the new scrollsnapchange event, scroll-padding for sticky headers, the 2026 CSS Carousel API (::scroll-marker and ::scroll-button), scroll-driven animations paired with snap, the 100dvh fix for mobile, drag-to-scroll for desktop, keyboard accessibility, and 'scroll snap not working' troubleshooting.

Two lines of CSS and you have a working carousel. No Swiper. No Slick. No JavaScript.

.carousel      { scroll-snap-type: x mandatory; overflow-x: auto; display: flex; }
.carousel-item { scroll-snap-align: start; flex: 0 0 100%; }

CSS scroll-snap controls how scrolling lands — making it stop at specific positions rather than wherever momentum runs out. This guide covers every property, the mandatory vs proximity decision, scroll-snap-stop for preventing slide-skipping, the new scrollsnapchange event that replaces IntersectionObserver hacks, the 2026 CSS Carousel API (::scroll-marker() and ::scroll-button() shipped in Chrome 135+ and Safari 18.2+), scroll-driven animations paired with snap, the 100dvh trick that fixes mobile, drag-to-scroll for desktop mouse users, keyboard accessibility, and a debug checklist for when CSS scroll snap is not working.

For the layout patterns this pairs with, see CSS position: sticky explained. For how cascade affects snap property overrides, see CSS specificity explained.

Live Demo

Live Demo Open in tab

Three tabs: ① interactive builder — toggle axis, mandatory/proximity, all 4 alignment values, ② five real-world patterns including a CSS carousel with synced dots, full-page sections, 2D grid snap, and chat auto-scroll, ③ scroll-snap-stop, the scrollsnapchange event with proper feature detection, and scroll-padding for sticky headers.

How CSS scroll-snap Works

Every scroll-snap setup needs two things: a scroll container with scroll-snap-type and child elements with scroll-snap-align. The container defines the direction and strictness; the children define where the snap points are.

/* Container */
.scroll-container {
  overflow-x: auto;
  scroll-snap-type: x mandatory;
}

/* Children */
.scroll-item {
  scroll-snap-align: start;
}

When scrolling stops, the browser moves the scroll position to the nearest declared snap point. The container handles the axis and strictness; the children handle their own alignment.

scroll-snap-type — Axis and Strictness

scroll-snap-type takes two values: the axis and the strictness.

Axis values

scroll-snap-type: x mandatory;      /* horizontal scrolling */
scroll-snap-type: y mandatory;      /* vertical scrolling */
scroll-snap-type: both mandatory;   /* both axes simultaneously — 2D snap */
scroll-snap-type: block mandatory;  /* logical — usually vertical (en) */
scroll-snap-type: inline mandatory; /* logical — usually horizontal (en) */

The logical block and inline values respect writing mode — in writing-mode: vertical-rl (Japanese, Mongolian), inline becomes vertical and block becomes horizontal. Use logical values when supporting RTL or vertical scripts.

Mandatory vs proximity — Which Strictness to Choose

This is the most important decision in scroll-snap and the one most tutorials fail to explain properly.

scroll-snap-type: x mandatory;  /* always snaps to a snap point */
scroll-snap-type: x proximity;  /* snaps only if close to a snap point */

mandatory — The scroll position always ends on a snap point. The browser may scroll slightly after you lift your finger to reach the nearest snap point. If snapped content doesn’t fully fill the container, it’s possible for content between snap points to become inaccessible (the accessibility trap — more on this later).

proximity — The browser only snaps if the final scroll position would naturally land close to a snap point. Users can scroll freely and rest between items. The exact “close enough” threshold is browser-determined.

Mandatory vs proximity — Decision Table

Use caseValue
Carousel where every slide should be fully visiblemandatory
Full-page hero sections on a landing pagemandatory
Long article with section anchorsproximity
Image gallery where users browse freelyproximity
Onboarding screensmandatory
Content-heavy blog postproximity

The rule: use mandatory when every item is the same size and should be fully visible. Use proximity when content may be taller than the viewport or when users need to read freely.

scroll-snap-align — Where Each Child Snaps

scroll-snap-align is set on the children and defines the snap point’s position within each child:

.item { scroll-snap-align: start; }   /* leading edge aligns with container edge */
.item { scroll-snap-align: center; }  /* center aligns with container center */
.item { scroll-snap-align: end; }     /* trailing edge aligns with container edge */
.item { scroll-snap-align: none; }    /* no snap point — item can be scrolled past */

start is correct for full-width carousels — the left edge of the slide aligns with the left edge of the container.

center is correct for peeking carousels — adjacent slides are partially visible on both sides and the active slide is centered.

end is correct for chat interfaces — the last message’s bottom edge aligns with the bottom of the container.

none explicitly opts a child out of being a snap target — useful when you want some items in the scroll container to be snap-able but others to be skipped.

/* Peeking carousel — show partial next/prev slides */
.peeking-carousel {
  scroll-snap-type: x mandatory;
  overflow-x: auto;
  padding-inline: 40px;          /* space for peeking slides */
  scroll-padding-inline: 40px;   /* offset the snap point to match */
}

.peeking-item {
  scroll-snap-align: center;
  flex: 0 0 80%;                 /* less than 100% — neighbouring slides visible */
}

scroll-snap-stop: always — Prevent Slide Skipping

By default, a fast swipe or trackpad gesture can skip multiple snap points in one motion. scroll-snap-stop: always forces the browser to stop at each snap point, even during a fast gesture:

/* ❌ Default — fast swipe can skip multiple slides */
.slide {
  scroll-snap-align: start;
  /* scroll-snap-stop defaults to normal */
}

/* ✅ scroll-snap-stop: always — forces stop at every slide */
.slide {
  scroll-snap-align: start;
  scroll-snap-stop: always;
}

Use scroll-snap-stop: always for:

  • Onboarding flows where each step must be seen
  • Image galleries where nothing should be skipped
  • Step-by-step tutorials

Use the default normal for:

  • Carousels where speed navigation is fine
  • Long lists where users want to skim quickly

Here’s a production-ready CSS carousel without JavaScript — full-width slides with a dot indicator:

<div class="carousel" id="carousel">
  <div class="slide">Slide 1</div>
  <div class="slide">Slide 2</div>
  <div class="slide">Slide 3</div>
  <div class="slide">Slide 4</div>
</div>

<!-- Dots are <button> for keyboard + screen-reader accessibility -->
<div class="carousel-dots" role="tablist" aria-label="Carousel pagination">
  <button class="dot active" type="button" aria-label="Go to slide 1"></button>
  <button class="dot" type="button" aria-label="Go to slide 2"></button>
  <button class="dot" type="button" aria-label="Go to slide 3"></button>
  <button class="dot" type="button" aria-label="Go to slide 4"></button>
</div>
.carousel {
  display: flex;
  overflow-x: auto;
  scroll-snap-type: x mandatory;
  scrollbar-width: none;                 /* Firefox + modern Chrome/Safari */
  -webkit-overflow-scrolling: touch;     /* iOS momentum scrolling */
}

.carousel::-webkit-scrollbar { display: none; } /* legacy WebKit */

.slide {
  flex: 0 0 100%;
  scroll-snap-align: start;
  scroll-snap-stop: always;
}

.dot {
  width: 8px; height: 8px;
  padding: 0;
  border-radius: 50%;
  background: rgba(255,255,255,0.3);
  border: none; cursor: pointer;
  transition: all 0.2s;
}

.dot:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; }

.dot.active {
  width: 24px;
  border-radius: 4px;
  background: white;
}

In 2026, the spec-driven way to build dot indicators and prev/next buttons is the CSS Carousel API — two new pseudo-elements that generate the navigation UI without any markup at all. Chrome 135+ shipped both in March 2025; Safari 18.2 in December 2025. As of mid-2026 this covers ~75% of global users.

Dot indicators with zero markup

The browser generates one ::scroll-marker per snap child. Define the style and it renders into a ::scroll-marker-group you place anywhere on the page:

.carousel {
  scroll-snap-type: x mandatory;
  overflow-x: auto;
  display: flex;
  /* Tell the browser where to render the marker group */
  scroll-marker-group: after;
}

.slide {
  flex: 0 0 100%;
  scroll-snap-align: start;
}

/* Style the auto-generated markers */
.slide::scroll-marker {
  content: '';
  display: inline-block;
  width: 8px;
  height: 8px;
  border-radius: 50%;
  background: rgba(0,0,0,0.3);
  margin: 0 4px;
  cursor: pointer;
}

/* The marker matching the currently snapped slide */
.slide::scroll-marker:target-current {
  background: currentColor;
  width: 24px;
  border-radius: 4px;
}

The browser handles click-to-scroll, keyboard arrow navigation, focus management, and ARIA — all of which you’d write JavaScript for previously. No scrollIntoView() call, no scrollsnapchange listener, nothing.

Prev / next buttons

::scroll-button() generates four pseudo-button positions on the scroll container itself:

.carousel::scroll-button(left),
.carousel::scroll-button(right) {
  content: '';
  position: absolute;
  width: 40px;
  height: 40px;
  border-radius: 50%;
  background: rgba(0,0,0,0.5);
  cursor: pointer;
}

.carousel::scroll-button(left)  { content: '‹'; left: 8px;  top: 50%; }
.carousel::scroll-button(right) { content: '›'; right: 8px; top: 50%; }

/* Disabled state when at the start/end */
.carousel::scroll-button(*):disabled {
  opacity: 0.3;
  pointer-events: none;
}

Progressive enhancement pattern

Wrap the new API in @supports so older browsers keep the manual carousel:

/* Base: manual <button class="dot"> carousel — works everywhere */
.dots-fallback { display: flex; gap: 6px; }

/* Enhanced: use ::scroll-marker where supported */
@supports selector(::scroll-marker) {
  .dots-fallback { display: none; } /* hide manual dots */
  .carousel { scroll-marker-group: after; }
  .slide::scroll-marker { /* …auto dots */ }
}

This is the future of CSS carousels. As of mid-2026, ship both — the manual implementation for Firefox (no support yet) and the spec implementation as an enhancement for Chrome/Safari users.

scrollsnapchange — Sync Indicators Without IntersectionObserver

For the manual carousel pattern (without ::scroll-marker), the scrollsnapchange event fires when the snapped element changes — no IntersectionObserver, no scroll event throttling, no guessing:

const carousel = document.getElementById('carousel');

// Feature-detect first
if ('onscrollsnapchange' in carousel) {
  carousel.addEventListener('scrollsnapchange', (e) => {
    // e.snapTargetInline = the newly snapped element (horizontal)
    // e.snapTargetBlock  = the newly snapped element (vertical)
    const newIndex = [...carousel.children].indexOf(e.snapTargetInline);
    updateDots(newIndex);
  });
} else {
  // Firefox + older browsers — IntersectionObserver fallback
  const observer = new IntersectionObserver((entries) => {
    entries.forEach(entry => {
      if (entry.isIntersecting && entry.intersectionRatio >= 0.5) {
        updateDots([...carousel.children].indexOf(entry.target));
      }
    });
  }, { root: carousel, threshold: 0.5 });

  [...carousel.children].forEach(slide => observer.observe(slide));
}

function updateDots(activeIndex) {
  document.querySelectorAll('.dot').forEach((dot, i) => {
    dot.classList.toggle('active', i === activeIndex);
  });
}

Browser support: Chrome 129+ (October 2024), Safari 18.2+ (December 2024). Firefox does not yet support it as of mid-2026.

scrollsnapchanging — fires during dragging

scrollsnapchanging fires during the drag — before the scroll settles — allowing you to preview which slide is pending:

carousel.addEventListener('scrollsnapchanging', (e) => {
  // fires while user is still dragging
  const pendingIndex = [...carousel.children].indexOf(e.snapTargetInline);
  previewDot(pendingIndex); // lighter visual indication
});

Use scrollsnapchanging for “ghost” indicator states; use scrollsnapchange for the committed state.

Scroll to a Snap Point Programmatically

For programmatic navigation (a “next slide” button outside the carousel, a deep link to a specific slide), use scrollIntoView() with explicit alignment options:

function goToSlide(carousel, index) {
  const target = carousel.children[index];
  if (!target) return;
  target.scrollIntoView({
    behavior: 'smooth',   // animate
    inline: 'start',      // horizontal carousel — align to inline start
    block: 'nearest',     // don't scroll the page vertically
  });
}

// Wire up the nav button
document.getElementById('next-btn').addEventListener('click', () => {
  const carousel = document.getElementById('carousel');
  const current = [...carousel.children].findIndex(
    el => el === document.elementFromPoint(/* …*/)
  );
  goToSlide(carousel, current + 1);
});

The snap engine takes over from there. Once scrollIntoView lands at the target, scroll-snap-type keeps it pinned.

scroll-padding — Fix the Sticky Header Problem

When you have a sticky navigation bar, snap targets snap to the top of the viewport — hiding behind the nav. scroll-padding-top creates an offset that accounts for the header height:

:root {
  --nav-height: 60px;
}

.page {
  overflow-y: auto;
  scroll-snap-type: y mandatory;
  scroll-padding-top: var(--nav-height); /* snap points start below the header */
}

/* Or on html/body for full-page snapping */
html {
  scroll-padding-top: var(--nav-height);
}

.section {
  scroll-snap-align: start; /* aligns with the padded snap port, not the raw top */
  min-height: 100dvh;       /* see 100dvh section below */
}

This is the same pattern as scroll-margin-top for anchor links — both solve the “hiding behind sticky header” problem, from different sides.

scroll-margin vs scroll-padding — The Confused Pair

These two properties are constantly mixed up. They solve related but different problems:

PropertyWhere it goesWhat it does
scroll-paddingOn the containerShrinks the snap port from the inside
scroll-marginOn the childPushes the snap point outward from the child
/* scroll-padding — shrinks the container's snap viewport */
.container {
  scroll-snap-type: y mandatory;
  scroll-padding-top: 60px; /* header height */
}

/* scroll-margin — adds space around a specific child's snap point */
.featured-section {
  scroll-snap-align: start;
  scroll-margin-top: 20px; /* extra offset for this section only */
}

Use scroll-padding when the offset applies to the whole container (fixed/sticky header). Use scroll-margin when the offset applies to a specific child only.

CSS Full-Page Scroll Without fullPage.js

<main class="page">
  <section class="section">Hero</section>
  <section class="section">Features</section>
  <section class="section">Pricing</section>
  <section class="section">Contact</section>
</main>
.page {
  height: 100dvh;                       /* see 100dvh note below */
  overflow-y: auto;
  scroll-snap-type: y mandatory;
  scroll-padding-top: 60px;             /* sticky nav height */
}

.section {
  min-height: 100dvh;                   /* fill the dynamic viewport */
  scroll-snap-align: start;
}

Important: Use proximity not mandatory for content-heavy sections. If a section’s content is taller than the viewport, mandatory will prevent users from reading the bottom of it. Switch to proximity when section heights vary.

Use 100dvh not 100vh — The Mobile Fix

The single biggest production bug in full-page scroll setups is using 100vh on mobile. iOS Safari’s collapsing toolbar means 100vh is taller than the visible viewport — your first snap section overflows the screen by 60-100 pixels, and the snap engine fights the toolbar collapse animation.

Use dynamic viewport units instead — Baseline since 2023, supported in all modern browsers:

.section {
  min-height: 100dvh;  /* dynamic — recalculates when toolbar shows/hides */
  /* min-height: 100svh;  shortest viewport (toolbar visible) */
  /* min-height: 100lvh;  largest viewport (toolbar hidden) */
}
  • dvh — adjusts as the browser UI shows/hides (recommended for full-page snap)
  • svh — always the smallest possible viewport (no resize)
  • lvh — always the largest possible viewport (overflow when toolbar shows)

For sticky scroll-padding-top, you usually want dvh so the snap port resizes with the visible viewport.

Animate Items as They Snap into View

Pair scroll-snap with scroll-driven animations (animation-timeline: view()) for free parallax-style effects — items scale up as they snap into the viewport center, fade in, or shift. Zero JavaScript. Chrome 115+ shipped this in 2023; Safari 26 ships it in 2026.

.carousel-item {
  flex: 0 0 100%;
  scroll-snap-align: start;
  /* Drive an animation from the item's position in the viewport */
  animation: snap-zoom linear;
  animation-timeline: view();
  animation-range: entry 0% cover 50%;
}

@keyframes snap-zoom {
  from { transform: scale(0.85); opacity: 0.6; }
  to   { transform: scale(1);    opacity: 1; }
}

When the item enters the viewport, the animation runs from fromto driven by the scroll position itself. When the item leaves, it reverses. No IntersectionObserver, no GSAP, no requestAnimationFrame loop — the animation timeline IS the scroll position.

Reduced-motion gate: wrap in @media (prefers-reduced-motion: no-preference) to respect users who’ve disabled animations.

Smooth Scrolling with Reduced-Motion Respect

scroll-behavior: smooth makes anchor links and scrollIntoView() animate to their target. Combined with scroll-snap, it makes click-to-slide feel polished. Always gate it behind prefers-reduced-motion — animated scrolling can trigger vestibular discomfort for some users.

/* Default: respect the user's reduced-motion preference */
@media (prefers-reduced-motion: no-preference) {
  html {
    scroll-behavior: smooth;
  }
  .carousel {
    scroll-behavior: smooth;
  }
}

With this in place, your “next slide” button animates the scroll for users who want motion and jumps instantly for users who don’t.

Drag-to-Scroll for Desktop Mouse Users

Horizontal carousels have one persistent UX problem on desktop: mouse wheels scroll vertically, not horizontally, so users without trackpads can’t navigate. Modern macOS/Windows trackpads handle horizontal swipes natively, but mouse-only users are stuck.

A short JS enhancement converts vertical wheel motion into horizontal scroll:

const carousel = document.querySelector('.carousel');

// Map wheel.deltaY to scrollLeft so mouse-wheel scrolls horizontally
carousel.addEventListener('wheel', (e) => {
  if (Math.abs(e.deltaY) > Math.abs(e.deltaX)) {
    e.preventDefault();
    carousel.scrollLeft += e.deltaY;
  }
}, { passive: false });

// Optional: click-and-drag for desktop mouse-drag scrolling
let isDown = false, startX, scrollLeft;
carousel.addEventListener('mousedown', (e) => {
  isDown = true;
  startX = e.pageX - carousel.offsetLeft;
  scrollLeft = carousel.scrollLeft;
  carousel.style.scrollSnapType = 'none'; // disable snap during drag
});
carousel.addEventListener('mouseup',    () => {
  isDown = false;
  carousel.style.scrollSnapType = '';    // re-enable snap (re-snaps on release)
});
carousel.addEventListener('mouseleave', () => { isDown = false; });
carousel.addEventListener('mousemove', (e) => {
  if (!isDown) return;
  e.preventDefault();
  const x = e.pageX - carousel.offsetLeft;
  carousel.scrollLeft = scrollLeft - (x - startX);
});

The scroll-snap-type: none during drag prevents the “slideshow effect” where every pixel of drag tries to snap to a new slide. Re-enabling on mouseup triggers a final snap to the nearest item.

Nested Scroll Containers with overscroll-behavior

When you have a horizontal carousel inside a vertically scrolling page, scrolling past the end of the carousel chains to the page scroll — which is usually jarring. overscroll-behavior prevents this:

.carousel {
  scroll-snap-type: x mandatory;
  overflow-x: auto;
  overscroll-behavior-x: contain; /* prevent scroll chaining to the page */
}

Without overscroll-behavior-x: contain, reaching the end of the carousel triggers the page to scroll vertically. With it, the scroll stops at the carousel’s edge.

Real Use Case: Chat — Keep Scrolled to Bottom

A clever use of scroll-snap-align: end on the last message keeps the chat scrolled to the most recent message:

<div class="chat-thread" id="thread">
  <div class="message">Hey, did you see this?</div>
  <div class="message">Not yet!</div>
  <div class="message last">Check the new CSS tutorial.</div>
</div>
.chat-thread {
  height: 400px;
  overflow-y: auto;
  scroll-snap-type: y proximity;
}

.message.last {
  scroll-snap-align: end; /* last message's bottom edge stays at container bottom */
}

When a new message is added, the JS also needs to scroll to it — browsers don’t re-snap on DOM mutation alone. After that initial scroll, scroll-snap-align: end keeps the latest message pinned as the user scrolls back up and releases:

function addMessage(text) {
  const thread = document.getElementById('thread');
  const old = thread.querySelector('.last');
  if (old) old.classList.remove('last');

  const msg = document.createElement('div');
  msg.className = 'message last';
  msg.textContent = text;
  thread.appendChild(msg);

  // Required — snap doesn't auto-fire on append
  msg.scrollIntoView({ behavior: 'smooth', block: 'end' });
}

Keyboard Accessibility and Tab Navigation

When a snap child receives Tab focus, the browser auto-scrolls it into view. scroll-padding governs where focus-scrolling lands — the same padding that protects against sticky headers also keeps focused elements out from behind them.

For full keyboard accessibility:

<!-- Each snap child should be focusable -->
<div class="carousel" tabindex="0" aria-label="Image gallery">
  <article class="slide" tabindex="0">…</article>
  <article class="slide" tabindex="0">…</article>
  <article class="slide" tabindex="0">…</article>
</div>
// Arrow-key navigation (optional polish — Tab already works)
carousel.addEventListener('keydown', (e) => {
  if (e.key === 'ArrowRight') {
    e.preventDefault();
    document.activeElement.nextElementSibling?.focus();
  } else if (e.key === 'ArrowLeft') {
    e.preventDefault();
    document.activeElement.previousElementSibling?.focus();
  }
});

With tabindex="0" on each slide and scroll-padding on the container, keyboard users can Tab through slides naturally — focus follows the snap.

CSS scroll-snap Not Working? Debug Checklist

Container isn’t snapping (the #1 cause of “scroll snap not working”):

  • Does the container have overflow: auto or overflow: scroll? Without overflow, there’s nothing to snap
  • Does the container have a defined height (for vertical) or width (for horizontal)? Without a fixed dimension, there’s no scroll
  • Is scroll-snap-type on the element that actually scrolls — not a wrapper?

Children don’t snap to the right position:

  • Is scroll-snap-align set on the direct children of the scroll container?
  • Try start, center, and end to see which aligns correctly with your design
  • Does the container need scroll-padding to account for a sticky header?

Scroll snap on mobile / iOS isn’t working:

  • Add -webkit-overflow-scrolling: touch to the container (legacy WebKit)
  • Ensure overflow-x: scroll (not auto) on the container for iOS Safari older than 15
  • Use 100dvh instead of 100vh for full-page sections — iOS toolbar collapse breaks 100vh
  • Percentage widths on snap children silently fail on iOS Safari — use explicit pixel widths or flex: 0 0 100%

Fast swipe skips multiple items:

  • Add scroll-snap-stop: always to each child

Dots indicator doesn’t update:

  • Is the browser Chrome 129+/Safari 18.2+ for scrollsnapchange?
  • On Firefox, use the IntersectionObserver fallback pattern (feature-detect with 'onscrollsnapchange' in element)

Mouse wheel doesn’t scroll the horizontal carousel:

Accessibility — The mandatory Trap

scroll-snap-type: mandatory can cause serious accessibility issues. If any content exists between snap points (like a section that’s taller than the viewport), or if any element isn’t designated as a snap target, that content may become unreachable by keyboard and assistive technology.

/* ❌ Dangerous — content between sections may be inaccessible */
.page {
  scroll-snap-type: y mandatory;
}

.long-section  { min-height: 200vh; } /* content overflows viewport */
.short-section { min-height: 100vh; }

Fixes:

  1. Use proximity for variable-height content
  2. Ensure every snap point is a snap target with scroll-snap-align
  3. Test with keyboard-only navigation (Tab key)
  4. Use @supports to apply snap as a progressive enhancement:
/* Base experience — free scrolling */
.page { overflow-y: auto; }

/* Enhanced experience — snap only where supported and predictable */
@supports (scroll-snap-align: start) {
  .page { scroll-snap-type: y proximity; }
  .section { scroll-snap-align: start; }
}

CSS scroll-snap vs JavaScript Slider Libraries

FeatureCSS scroll-snapSwiper / Slick
Bundle size0KB30–100KB
GPU acceleration✅ NativeVaries
Touch support✅ Built-in✅ Built-in
Dot indicators (modern)::scroll-marker()✅ Built-in
Dot indicators (legacy)Needs scrollsnapchange✅ Built-in
Mouse wheel horizontalNeeds 8-line JS✅ Built-in
Autoplay❌ Needs JS✅ Built-in
Complex transitionsPair with scroll-driven animations✅ Full control
Keyboard navigation✅ Native (with tabindex)
Lazy loadingCombine with native loading="lazy"Often built-in

Use CSS scroll-snap when: You need a carousel, full-page scroll, or 2D grid snap. No autoplay-by-default, no complex slide transitions — though scroll-driven animations close most of that gap in 2026.

Use a JS library when: You need autoplay, thumbnail navigation with cross-fading, fade or 3D-perspective slide transitions, or deep customization that doesn’t fit the snap model.

Browser Support

CSS scroll-snap core — Baseline, fully supported in all modern browsers: Chrome 69+, Firefox 68+, Safari 11+, Edge 79+. Covers 97%+ of global users in 2026.

scroll-snap-stop — Chrome 75+, Firefox 103+, Safari 15+.

scrollsnapchange / scrollsnapchanging events — Chrome 129+ (October 2024), Safari 18.2+ (December 2024). Firefox support is not yet shipped as of mid-2026. Use the IntersectionObserver polyfill for Firefox.

::scroll-marker() / ::scroll-button() (CSS Carousel API) — Chrome 135+ (March 2025), Safari 18.2+ (December 2025). Firefox in development. Use @supports selector(::scroll-marker) to gate.

100dvh / 100svh / 100lvh — All modern browsers since 2023. Baseline.

overscroll-behavior — Chrome 63+, Firefox 59+, Safari 16+.

Scroll-driven animations (animation-timeline: view()) — Chrome 115+, Safari 26+, Firefox in development.

Key Takeaways

  • scroll-snap-type on the container + scroll-snap-align on children = working carousel
  • Mandatory vs proximity: mandatory always snaps — use for carousels and full-page layouts. proximity snaps when nearby — use for content-heavy pages
  • scroll-snap-stop: always prevents fast swipes from skipping multiple slides
  • The 2026 CSS Carousel API (::scroll-marker() / ::scroll-button()) generates dot indicators and prev/next buttons without markup — Chrome 135+/Safari 18.2+
  • scrollsnapchange replaces IntersectionObserver for syncing indicators — Chrome 129+/Safari 18.2+, with IntersectionObserver as Firefox fallback. Always feature-detect with 'onscrollsnapchange' in element
  • Pair scroll-snap with scroll-driven animations (animation-timeline: view()) for free parallax effects — gate with prefers-reduced-motion
  • scroll-padding-top fixes the snap-behind-sticky-header problem
  • scroll-padding is on the container; scroll-margin is on the child — different use cases
  • Use 100dvh not 100vh for full-page snap — iOS toolbar collapse breaks 100vh
  • Add scroll-behavior: smooth for animated scrolling, gated by prefers-reduced-motion
  • For desktop mouse users, add a short wheel-to-scrollLeft JS handler
  • overscroll-behavior-x: contain prevents scroll chaining from nested carousels to the page
  • mandatory can trap keyboard users if content overflows the viewport — use proximity or test carefully

FAQ

Set scroll-snap-type: x mandatory and overflow-x: auto on the container, then scroll-snap-align: start and flex: 0 0 100% on each slide. Add scrollbar-width: none to hide the scrollbar. That’s a working full-width CSS carousel without JavaScript in 5 lines of CSS. For dot indicators in 2026, use ::scroll-marker() natively (Chrome 135+/Safari 18.2+) or the scrollsnapchange event with IntersectionObserver fallback for older browsers.

What is the difference between mandatory and proximity in scroll-snap?

mandatory always moves the scroll to a snap point — the scroll never rests between items. proximity only snaps if the scroll would naturally end close to a snap point. Use mandatory for carousels and step-by-step layouts where every item should be fully visible. Use proximity for content-heavy pages where users need to freely read within a section.

Why is my CSS scroll-snap not working?

Check in this order: (1) Does the container have overflow: auto or overflow: scroll? Without overflow there is nothing to snap. (2) Does the container have an explicit height (vertical) or width (horizontal)? (3) Is scroll-snap-type on the element that actually scrolls, not a non-scrolling wrapper? (4) Is scroll-snap-align set on the direct children of the scroll container? On mobile/iOS: use 100dvh not 100vh, and use explicit pixel widths on snap children rather than percentages.

What is scroll-snap-stop: always?

scroll-snap-stop: always on a child forces the browser to stop at that element’s snap point during any scroll, even a fast swipe. Without it, a fast gesture can skip multiple snap points. Use it for carousels where no slide should ever be skipped — like onboarding flows or galleries.

What is the scrollsnapchange event?

scrollsnapchange is a JavaScript event that fires when the scroll container settles on a new snap target. It’s available in Chrome 129+ and Safari 18.2+. It’s the clean alternative to IntersectionObserver for syncing carousel dots, labels, or counters with the currently visible slide. Use the 'onscrollsnapchange' in element feature-detect and fall back to IntersectionObserver for Firefox.

How do I fix snap targets hiding behind a sticky header?

Add scroll-padding-top to the scroll container equal to the sticky header’s height. For full-page HTML scrolling: html { scroll-padding-top: 60px; }. For a custom scroll container: .container { scroll-padding-top: 60px; }. The snap points will now land below the header instead of behind it.

How do I scroll to a specific snap point with JavaScript?

Use scrollIntoView with explicit alignment options: target.scrollIntoView({ behavior: 'smooth', inline: 'start', block: 'nearest' }). For horizontal carousels, inline: 'start' aligns the target to the inline-start edge of the scroll container. The snap engine then takes over and keeps the target pinned. Wrap in requestAnimationFrame if you call it immediately after appendChild so the new element is laid out first.

The CSS Carousel API is a set of pseudo-elements added to CSS Overflow Level 5. ::scroll-marker() generates a dot indicator per snap child without any markup — the browser handles click-to-scroll, keyboard navigation, and ARIA automatically. ::scroll-button() generates prev/next buttons on the scroll container itself. Chrome 135+ shipped both in March 2025; Safari 18.2 in December 2025. Wrap in @supports selector(::scroll-marker) for progressive enhancement.

Can I scroll snap on mobile and iOS?

Yes — scroll-snap-type works in iOS Safari 11+ and all mobile browsers. Two iOS-specific gotchas: (1) use 100dvh not 100vh for full-page sections because iOS Safari’s collapsing toolbar makes 100vh taller than the visible viewport; (2) use explicit pixel widths on snap children rather than percentages — percentage widths silently fail in older iOS Safari versions.

How do I snap to a CSS grid?

Set scroll-snap-type: both mandatory (or both proximity) on the grid container and scroll-snap-align: start on each grid item. Both X and Y axes snap simultaneously. The container needs overflow: auto on both axes for snapping to work. This is the foundation pattern for map-like interfaces and 2D paginated content.

Mouse wheels only scroll vertically by default. Add a small JS handler that maps wheel.deltaY to scrollLeft: carousel.addEventListener('wheel', e => { e.preventDefault(); carousel.scrollLeft += e.deltaY; }, { passive: false }). Combine with scroll-behavior: smooth and scroll-snap-type for a polished result. Modern macOS/Windows trackpads handle horizontal swipes natively, so this is mostly for desktop mouse users.