DevDockTools

CSS Scroll Snap: The Complete Guide With Working Examples

Build carousels, full-page sections, and image galleries with native CSS scroll-snap — no JavaScript library required. Covers axis, strictness, and mobile pitfalls.

By Daniel Agrici5 min read
cssscroll-snapcarouselcss layoutweb development

What Scroll Snap Actually Solves

Before scroll-snap-type, carousels and paginated sections required either a JavaScript carousel library or manual scrollLeft/scrollTop math tied to touch and wheel event listeners. CSS scroll snap moved that entirely into the browser's native scrolling and gesture-handling pipeline, which means it inherits accelerated scrolling, momentum, and accessibility behavior for free.

The core mental model: a scroll container declares that it wants to snap, and its children declare where the snap points are.

.container {
  scroll-snap-type: x mandatory;
  overflow-x: auto;
  display: flex;
}

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

That's a complete, functioning full-width carousel. No JS.

The Properties, in Practice

scroll-snap-type: axis and strictness

scroll-snap-type: x mandatory;   /* horizontal, always snaps */
scroll-snap-type: y proximity;   /* vertical, snaps loosely */
scroll-snap-type: both mandatory; /* 2D grid-style snapping */

mandatory guarantees the container comes to rest on a snap point. proximity only snaps if the natural scroll deceleration ends near one — a fast flick can land between items. For image carousels and full-page sections, mandatory is almost always what users expect. For long vertically-scrolling lists (like a feed with occasional snap-worthy cards), proximity feels less jarring.

scroll-snap-align: where each child snaps

.item { scroll-snap-align: start; }  /* item's start edge aligns to container's snap area */
.item { scroll-snap-align: center; } /* item centers in the container */
.item { scroll-snap-align: end; }    /* item's end edge aligns */

center is the right choice for "focused item" carousels — image lightboxes, testimonial sliders — where you want the active item visually centered rather than flush against an edge.

scroll-padding and scroll-margin: accounting for sticky headers

A common bug: scroll snap lands items exactly at the container's edge, which is wrong when you have a sticky header overlapping the top of the scroll area.

.container {
  scroll-snap-type: y mandatory;
  scroll-padding-top: 4rem; /* height of the sticky header */
}

scroll-padding shifts where "start" means, without changing layout — the equivalent of scroll-margin-top but applied to the container rather than each item. Use scroll-margin on individual items instead when different children need different offsets (e.g., the first card needs extra clearance but the rest don't).

<div class="carousel">
  <div class="carousel-item">1</div>
  <div class="carousel-item">2</div>
  <div class="carousel-item">3</div>
  <div class="carousel-item">4</div>
</div>
.carousel {
  display: flex;
  gap: 1rem;
  overflow-x: auto;
  scroll-snap-type: x mandatory;
  scroll-padding-inline: 1rem;
  padding-inline: 1rem;
  /* hide scrollbar while keeping it functional */
  scrollbar-width: none;
}
.carousel::-webkit-scrollbar {
  display: none;
}

.carousel-item {
  scroll-snap-align: start;
  scroll-snap-stop: always;
  flex: 0 0 min(80%, 320px);
  aspect-ratio: 4 / 3;
  border-radius: 0.75rem;
  box-shadow: 0 4px 12px rgb(0 0 0 / 0.12);
}

scroll-snap-stop: always forces the browser to stop at every snap point in sequence even during a fast fling, rather than skipping past several items — worth setting explicitly on carousels where skipping items would disorient the user. If you're tuning that card shadow value, the Box Shadow Generator is a quick way to preview elevation without round-tripping through DevTools each time.

Full-Page Vertical Sections

html {
  scroll-snap-type: y mandatory;
  height: 100%;
  overflow-y: scroll;
}

section {
  height: 100vh;
  scroll-snap-align: start;
}

This is the "one section per screen" pattern used on landing pages. Watch for one subtlety: putting scroll-snap-type on html rather than a wrapper div avoids double-scrollbar issues that show up when a nested container also has overflow, but it also means the whole page loses normal free scrolling — test carefully on content-heavy sections that might need to scroll internally.

Use native CSS scroll snap when:

  • The interaction is fundamentally "swipe/scroll to move between items" — carousels, image galleries, full-page sections, horizontally-scrolling card lists.
  • You want native touch/trackpad/keyboard scrolling behavior rather than reimplementing it.
  • You don't need infinite looping, autoplay, or complex custom transition easing between slides.
  • Bundle size matters — scroll snap adds zero JavaScript.

Reach for a JS carousel library when:

  • You need infinite/looping carousels (scroll snap has no native loop-back).
  • You need autoplay with pause-on-hover/interaction.
  • You need fade or custom transform-based transitions instead of physical scrolling.
  • You need synchronized multi-carousel behavior (e.g., a thumbnail strip driving a main image viewer).

A hybrid is common and often the right call: use scroll snap for the physical scrolling mechanism and layer a small amount of JS on top only for pagination dots or programmatic "next" buttons, using scrollTo({ behavior: 'smooth' }) against the snap points rather than reimplementing scrolling from scratch.

document.querySelector('.next-btn').addEventListener('click', () => {
  const container = document.querySelector('.carousel');
  const item = container.querySelector('.carousel-item');
  container.scrollBy({ left: item.offsetWidth + 16, behavior: 'smooth' });
});

Mobile and Cross-Browser Notes

Scroll snap has solid support across modern browsers, including iOS Safari and Chrome for Android, but a few things trip people up on mobile specifically:

  • Nested scroll-snap containers (a snapping carousel inside a snapping vertical page) can produce inconsistent gesture handling — test on real devices, not just DevTools device emulation.
  • scroll-snap-stop: always on an item taller than the viewport can trap the user's scroll gesture inside that one section. Only use it on items that fit within a single screen or scroll gesture.
  • Always pair horizontal scroll-snap containers with visible affordances (partial next-item peeking, or explicit arrows) — a full-bleed snapping carousel with no visual cue that more content exists is easy for users to miss entirely.

Start with a single-axis carousel, confirm snapping feels right with real touch and trackpad input, then layer in padding/margin adjustments for sticky headers before shipping.

Frequently Asked Questions

Does CSS scroll-snap work without JavaScript?
Yes, entirely. scroll-snap-type on the scroll container plus scroll-snap-align on children is enough for snapping behavior, keyboard navigation, and touch gestures. JavaScript is only needed for programmatic control like next/prev buttons or pagination dots.
Why isn't my scroll-snap working on iOS Safari?
The most common cause is a missing explicit height or overflow value on the scroll container, or scroll-snap-stop being set to always on an element that's taller than the viewport, which can trap scrolling. Also confirm -webkit-overflow-scrolling isn't set to a value that conflicts with native snap behavior.
What's the difference between mandatory and proximity snapping?
mandatory forces the scroll container to always come to rest on a snap point after a scroll gesture ends. proximity only snaps if the scroll naturally stops near a snap point, so a fast or long scroll can end up between snap points. proximity feels less aggressive and is often better for long content lists.