A CSS-Only Carousel With Scroll Snap

Carousels are the component most often rebuilt from scratch with a JavaScript library, and the one most often shipped broken: swipe works but the keyboard does not, momentum scrolling feels wrong, a screen reader announces slides that are off screen as if they were visible, and the whole thing weighs more than the images. The narrow problem this page solves is a horizontal carousel that feels native on touch, trackpad and mouse wheel, works from the keyboard, and needs no script — built from an ordinary overflow container plus CSS Scroll Snap. It belongs to Scroll Snap & Overflow Layouts in the Mastering Container Queries & Responsive Layouts guide.

Why scroll snap instead of a script

A scripted carousel reimplements scrolling: it listens for pointer events, computes velocity, animates a transform and decides where to stop. Each of those steps is an opportunity to feel slightly unlike the platform. Native scrolling already has momentum tuned per device, already works with trackpads, wheels, touch, arrow keys and screen-reader scrolling gestures, and already runs on the compositor so it stays smooth when the main thread is busy.

Scroll snap adds exactly one thing to native scrolling: a set of resting positions. When the user finishes a scroll gesture, the browser adjusts the final position to the nearest snap point, using the same momentum physics as the gesture itself. Everything else stays native. That is why a scroll-snap carousel feels right on every device without device-specific code.

What CSS alone does not give you is state: a "slide 3 of 7" indicator that updates as you scroll, or disabling the previous button at the start. Those need either newer CSS features still arriving in browsers or a few lines of script. The pattern below is complete without them and can be enhanced later.

How snapping works

Three pieces cooperate. The scroll container declares scroll-snap-type, choosing the axis and strictness. Each child declares scroll-snap-align, choosing which of its edges should line up. And the container's scroll-padding insets the snapport — the area against which alignment is measured — so slides can rest inside visual padding.

The snapport and snap alignment A scroll container with padding on both sides. The snapport is the inner region after scroll-padding. The current slide's start edge aligns with the snapport's start edge; the next slide peeks in from the right. Slides align to the snapport, not the box scroll container (scroll-snap-type: x mandatory) current slide scroll-snap-align: start next peeks padding padding snapport = container minus scroll-padding Match scroll-padding to the visual padding so the first slide rests where the design expects.

The peek of the next slide is not decoration. It is the most important affordance a carousel has: it tells the user there is more content to the side. Carousels that fit exactly one slide per view hide that fact, and users who never try swiping never see slides two to seven.

The complete implementation

Live demoScroll-snap carousel with jump links
Swipe, use the trackpad, or focus the row and press the arrow keys: each gesture comes to rest on a slide, and the next slide always peeks in.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Scroll-snap carousel</title>
<style>
  body { font: 15px/1.5 system-ui, sans-serif; margin: 1.5rem; }

  .carousel {
    display: grid;
    /* Each slide is 80% of the scroller so the next one peeks in. */
    grid-auto-flow: column;
    grid-auto-columns: 80%;
    gap: 1rem;

    overflow-x: auto;
    overscroll-behavior-x: contain;   /* do not trigger back-swipe at the ends */
    scroll-snap-type: x mandatory;    /* always come to rest on a slide */
    scroll-padding-inline: 1rem;      /* snapport matches the visual padding */
    padding: 0.5rem 1rem 1rem;

    /* Smooth scrolling for the next/previous links, but only for users
       who have not asked for reduced motion. */
    scroll-behavior: auto;
  }
  @media (prefers-reduced-motion: no-preference) {
    .carousel { scroll-behavior: smooth; }
  }

  .carousel:focus-visible {
    outline: 2px solid #2563eb;
    outline-offset: 2px;
  }

  .slide {
    scroll-snap-align: start;
    /* Stop fast flicks from skipping slides: each gesture ends on the
       next or previous slide at most. */
    scroll-snap-stop: always;
    min-height: 10rem;
    padding: 1rem;
    border-radius: 12px;
    background: linear-gradient(135deg, #dbeafe, #c7d2fe);
    color: #1e1b4b;
  }

  /* Prev/next are ordinary in-page links to slide ids; the browser
     scrolls the carousel, and snap settles the position. */
  .carousel-nav { display: flex; gap: 0.5rem; margin-top: 0.5rem; }
  .carousel-nav a {
    padding: 0.4rem 0.8rem;
    border: 1px solid #94a3b8;
    border-radius: 6px;
    color: inherit;
    text-decoration: none;
  }
</style>
</head>
<body>
  <section aria-labelledby="c-title">
    <h2 id="c-title">Featured trails</h2>
    <div class="carousel" tabindex="0" role="group" aria-label="Featured trails, scroll horizontally">
      <article class="slide" id="s1"><h3>Ridge Loop</h3><p>12 km, moderate.</p></article>
      <article class="slide" id="s2"><h3>Lake Path</h3><p>6 km, easy.</p></article>
      <article class="slide" id="s3"><h3>Summit Trail</h3><p>18 km, hard.</p></article>
      <article class="slide" id="s4"><h3>Forest Walk</h3><p>4 km, easy.</p></article>
    </div>
    <nav class="carousel-nav" aria-label="Jump to trail">
      <a href="#s1">1</a><a href="#s2">2</a><a href="#s3">3</a><a href="#s4">4</a>
    </nav>
  </section>
</body>
</html>

The grid with grid-auto-flow: column is the cleanest way to lay slides out in a row with consistent widths; a flex row with flex: 0 0 80% works equally well. The tabindex="0" on the scroller is what makes keyboard scrolling possible — a focused scroll container responds to arrow keys, Page Up and Page Down — and the label tells screen-reader users what the region is and how to use it.

Two features turn a snapping row into a usable carousel. scroll-snap-stop: always stops a fast flick from sailing past several slides. By default, a hard swipe can travel across many snap points and settle on a distant one, which is fine for a long list but disorienting in a carousel where each slide deserves a look. With always, a gesture can pass at most one snap point.

scroll-snap-stop: normal versus always Two rows of five slides. In the top row, labelled normal, a fast flick from slide one ends on slide four. In the bottom row, labelled always, the same flick ends on slide two. The same fast flick, two settings normal 1 lands on 4 always 1 lands on 2

The second feature is simply HTML. A link to #s3 asks the browser to scroll #s3 into view, and when the target is inside a horizontal scroll container, it is that container that scrolls. Snap then settles the position, and scroll-behavior: smooth animates the jump. The result is a working "go to slide" control built from anchor elements, which are keyboard-accessible, work without script, and are announced correctly by assistive technology. The one side effect is a changed URL hash; for most content carousels that is harmless or even useful, since it makes individual slides linkable.

Variation: responsive slide widths with container queries

80% slides are right on a phone and wasteful on a desktop, where two or three slides should be visible at once. A container query on the carousel's wrapper adjusts the column width without touching the snap rules, because snap alignment is computed from whatever size the slides end up.

.carousel-wrap { container-type: inline-size; }

/* Two slides plus a peek on medium containers. */
@container (width > 36rem) {
  .carousel { grid-auto-columns: calc(45% - 0.5rem); }
}

/* Three slides plus a peek on wide containers. */
@container (width > 60rem) {
  .carousel { grid-auto-columns: calc(30% - 0.66rem); }
}

/* Center alignment reads better when several slides are visible. */
@container (width > 60rem) {
  .slide { scroll-snap-align: center; }
}

The same wrapper is a natural place to add entry motion as slides scroll into view. With scroll-driven animations, a slide can fade and scale slightly as it enters the snapport using a view() timeline on the horizontal axis; the pattern is developed for vertical scrolling in Scroll-Triggered Reveal Animations and works identically with view(inline). Keep such motion subtle and wrapped in a prefers-reduced-motion: no-preference query, because horizontal motion in a carousel is already a lot of movement.

Accessibility checklist

  • Label the region. A heading plus an aria-label or aria-labelledby on the scroller says what it contains.
  • Make it focusable. tabindex="0" lets keyboard users scroll it; show a visible focus ring.
  • Keep slides in order. The DOM order is the reading order; never reorder slides visually.
  • Do not hide off-screen slides from assistive technology. They are real content that a screen-reader user can read linearly, which is often a better experience than a scripted carousel that hides them.
  • No auto-advance. Content that moves on its own for more than five seconds needs a pause control under WCAG 2.2.2; a scroll-snap carousel only moves when the user moves it.
  • Visible controls. Provide the jump links or buttons for users who cannot swipe or do not discover horizontal scrolling.

Browser support

CSS Scroll Snap — scroll-snap-type, scroll-snap-align, scroll-snap-stop and scroll-padding — is supported in all current versions of Chrome, Edge, Firefox and Safari. scroll-behavior: smooth is supported in Chrome 61+, Edge 79+, Firefox 36+ and Safari 15.4+; elsewhere the jump links scroll instantly, which is still correct. Container queries for responsive slide widths need Chrome and Edge 105+, Firefox 110+ or Safari 16+. In an engine without scroll snap, the carousel is still a horizontally scrolling row of slides — fully usable, just without resting positions.

FAQ

Is a scroll-snap carousel accessible without JavaScript? It can be more accessible than many scripted carousels. The slides stay in normal document order, the scroller can receive keyboard focus and be scrolled with arrow keys, and nothing auto-advances. Give the scroller a label, make it focusable, and keep every slide's content reachable without hover.

What is the difference between mandatory and proximity snapping?mandatory always settles on a snap point when scrolling stops, even if the user stopped between two. proximity only snaps when the resting position is close to a snap point. Use mandatory for one-slide-at-a-time carousels and proximity for long rows where users may want to rest anywhere.

Why does my carousel snap the first slide under a notch or padding? Snap alignment is computed against the scroller's snapport, which is its box minus scroll-padding. Without scroll-padding, start-aligned slides snap flush to the scroller's edge. Set scroll-padding-inline to match the visual padding so slides come to rest inside it.

Should a carousel auto-advance? Avoid it. Auto-advancing content that lasts more than five seconds must have a pause control under WCAG 2.2.2, and moving content distracts and disorients many users. A scroll-snap carousel that only moves when the user moves it avoids the problem entirely.

Related articles

More pages in the same section.