Scroll-snap carousels
CSS scroll-snap makes the browser's scroll position the carousel — touch, trackpad, and keyboard work for free, with new pseudo-elements for controls.
In one line: a carousel is just an overflow container with scroll-snap-type — the native scroll position is the source of truth, so swipe, trackpad, and keyboard all work before you write a line of JavaScript.
What it is
The classic carousel is a heap of JavaScript: an index variable, transform math, touch handlers, momentum, resize recalculation. CSS scroll-snap throws most of that away. You make a horizontally scrollable track and tell the browser where slides should come to rest:
scroll-snap-type: x mandatoryon the scroll container — scrolling always settles on a snap point along the x axis.scroll-snap-align: start(orcenter) on each slide — the edge or centre that lines up.
The browser's scroll position is the carousel state. Touch flings, trackpad swipes, Page Up/Down, and clicking the scrollbar all work natively, with the platform's own physics.
Why it matters
Less code, more correct. Native scrolling brings momentum, accessibility, and reduced-motion support you would otherwise reimplement (badly). And 2026 adds CSS-only controls that used to force you back into JS: ::scroll-button() generates real prev/next buttons, and ::scroll-marker / scroll-marker-group generate the dot indicators — keyboard-focusable and synced to scroll position, no state to track.
You reach for JavaScript only to enhance — e.g. a Prev/Next button calling scrollIntoView() for browsers without ::scroll-button() — never to make the thing work at all.
See it
Tweak it2
Use Prev/Next to move through the slides and watch them snap into place. Switch the snap alignment between start and centre, and toggle mandatory vs proximity snapping.
How it works
Snapping is a conversation between container and children. The container declares the axis and strictness (mandatory always snaps; proximity only snaps when you stop near a point). Each child declares which of its edges is the snap target.
Because scrolling drives everything, your "go to slide" controls just scroll: element.scrollIntoView({ inline: "start", behavior: "smooth" }) moves the chosen slide into view and the snap settles it precisely. scroll-padding on the container offsets snap points past a sticky header. For the controls themselves, prefer the generated ::scroll-button() / ::scroll-marker where supported and treat hand-built buttons as the fallback.
Build it
<div class="carousel" role="group" aria-roledescription="carousel" aria-label="Featured">
<ul class="track">
<li class="slide" aria-label="Slide 1 of 3">One</li>
<li class="slide" aria-label="Slide 2 of 3">Two</li>
<li class="slide" aria-label="Slide 3 of 3">Three</li>
</ul>
</div>.track {
display: flex;
gap: 1rem;
overflow-x: auto;
scroll-snap-type: x mandatory;
scroll-padding: 1rem;
}
.slide {
flex: 0 0 80%;
scroll-snap-align: start;
}
/* Progressive enhancement: generated prev/next buttons where supported. */
.track::scroll-button(left) { content: "\2039"; }
.track::scroll-button(right) { content: "\203A"; }
@media (prefers-reduced-motion: no-preference) {
.track { scroll-behavior: smooth; }
}Make it yours
Use the controls beside the demo above to change snap alignment and mandatory snap — each change updates the example live.
Experiment in the playground- Switch snap alignment to centre — the resting slide sits mid-track instead of flush left.
- Drop from mandatory to proximity and notice it only snaps when you let go near a slide, never fighting a deliberate mid-scroll stop.
- Picture the same markup with
::scroll-markerdots: focusable, keyboard-navigable, and synced to scroll with no index variable.
Reproduce it with an LLM
Reproduce it with an LLM
You are a senior front-end engineer. Build an accessible, library-free carousel using CSS scroll-snap: a horizontally scrollable track (`overflow-x: auto; scroll-snap-type: x mandatory`) of slides (`scroll-snap-align: start; flex: 0 0 80%`). The native scroll position is the source of truth — it works with touch, trackpad, and keyboard out of the box. Add Prev/Next `<button>`s that call `scrollIntoView({ inline })`, a `role="group"` with `aria-roledescription="carousel"` and a label, per-slide labels ("Slide n of m"), and a `prefers-reduced-motion` check that switches smooth scrolling to instant. Don't trap focus or hijack the scrollbar. Return only the HTML, CSS, and minimal JS.
Pitfalls & accessibility
Related
Scroll-driven reveals
Let content fade and rise into place as it enters the viewport — an enhancement that must never hide content when JavaScript doesn't run.
The View Transitions API
The View Transitions API animates between two DOM states — including a shared element that morphs across a navigation — with the browser doing the work.