Two properties make a carousel
scroll-snap-type goes on the element that scrolls and names the axis and how strict to be. scroll-snap-align goes on each child and says which of its edges should land on the snap line. Here the row scrolls sideways and every slide lines up by its start:
<div class="carousel" tabindex="0" role="region" aria-label="Projects">
<article class="slide">…</article>
<article class="slide">…</article>
<article class="slide">…</article>
<article class="slide">…</article>
<article class="slide">…</article>
</div>
<button type="button" class="carousel-prev">Previous</button>
<button type="button" class="carousel-next">Next</button>
<p class="carousel-status" aria-live="polite"></p>.carousel {
display: flex;
gap: 16px;
overflow-x: auto;
padding-inline: 24px;
scroll-padding-inline: 24px;
scroll-snap-type: x mandatory;
}
.slide {
flex: 0 0 80%;
scroll-snap-align: start;
scroll-snap-stop: always;
}The snap line is not the edge of the box but the edge of the snapport: the visible area minus scroll-padding. With scroll-padding-inline: 24px each slide lands 24px in from the left, as Fig. 1 draws. The padding-inline of the same 24px matters too: it puts the first slide on that line before any scroll, so every slide lands in the same place. Without it the first slide would want a scrollLeft of −24, which is clamped to 0, and it alone would sit flush against the edge.
flex: 0 0 80% is 80% of the box inside its padding, so a fifth of the next slide shows and the reader can see there is more. scroll-snap-stop: always makes a fast swipe stop at the next slide instead of flying past three; leave it out for a row of small cards, where skipping is what people want. The tabindex, role and aria-label are for the keyboard, further down.
The CSS scroll snap generator writes this CSS for any axis, alignment, padding and size, and draws the snap line and every snap position over a preview you can swipe.
Mandatory or proximity
The second word of scroll-snap-type decides how hard the snap pulls. mandatory always comes to rest on a snap position when a scroll ends. proximity snaps only when the scroll ends near one, and how near is up to the browser. scroll-snap-type: x with no second word is proximity, which is why a carousel written that way sometimes stops between slides.
A child taller (or wider) than the box does not trap anyone. In Chromium, Firefox and WebKit, a section twice the height of the window scrolls through as normal, and the snap only takes hold again past its end. What mandatory does trap is content with no snap position at all. We put 1,500px of ordinary text between two snapping sections and a 300px footer after them: in all three browsers every scroll came to rest on one section or the other, the text was never on screen when it stopped, and the footer never fully came into view, not even with the End key. With proximity both could be read.
Previous and next buttons
A touch screen and a trackpad can swipe the row, but a mouse wheel scrolls up and down, so a carousel needs buttons. Each one scrolls one slide along, smoothly unless the reader has asked their system for less motion:
const carousel = document.querySelector('.carousel');
const slides = [...carousel.querySelectorAll('.slide')];
const reduce = matchMedia('(prefers-reduced-motion: reduce)');
// One slide plus the gap: the distance between two snap positions.
const step = () => slides[1].offsetLeft - slides[0].offsetLeft;
function go(by) {
const index = Math.round(carousel.scrollLeft / step()) + by;
carousel.scrollTo({
left: index * step(),
behavior: reduce.matches ? 'instant' : 'smooth',
});
}
document.querySelector('.carousel-prev').addEventListener('click', () => go(-1));
document.querySelector('.carousel-next').addEventListener('click', () => go(1));Because the padding and the scroll-padding are equal, slide i snaps at exactly i × step, so the script rounds the scroll position to find the slide it is on and scrolls to the next one’s position. Past the end, scrollTo is clamped to the furthest the box can scroll, which is where the last slide snaps anyway.
The shorter version, carousel.scrollBy({ left: step() }), has a catch: the smooth scroll it starts is not snapped again in WebKit. From the last slide, whose position is clamped, Previous went back one step and stopped between two slides. Scrolling to a snap position directly does not depend on the browser fixing it. 'instant' rather than 'auto' for reduced motion means a scroll-behavior: smooth somewhere in your CSS cannot turn it back into a glide.
Which slide is showing
To say “Slide 2 of 5”, the script needs the snapped slide. Chromium 129 and later fire scrollsnapchange on the box when a scroll settles on a new snap target, with the element in event.snapTargetInline (or snapTargetBlock for a vertical one). Firefox and Safari do not have it yet, so the fallback is an IntersectionObserver on the slides, with the box as its root:
const status = document.querySelector('.carousel-status');
function show(index) {
status.textContent = 'Slide ' + (index + 1) + ' of ' + slides.length;
}
show(0);
if ('onscrollsnapchange' in window) {
// Chromium 129 and later: the browser says which slide it snapped to.
carousel.addEventListener('scrollsnapchange', (event) => {
show(slides.indexOf(event.snapTargetInline));
});
} else {
// Elsewhere: the slide that is at least 60% inside the box.
const observer = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (entry.intersectionRatio >= 0.6) show(slides.indexOf(entry.target));
}
}, { root: carousel, threshold: 0.6 });
slides.forEach((slide) => observer.observe(slide));
}The 60% works because of the sizes: a slide is 80% of the box, so the current one is wholly inside it and the slide peeking in shows only about a quarter of itself. Change the slide width and check that still holds. The two paths differ in timing: the event fires once the scroll has settled, the observer while it is still moving, as the next slide passes 60%.
The status line is a polite live region, so a screen reader announces the new slide after each move. The buttons are not disabled at the ends: a disabled button loses focus, and a keyboard user pressing Next would be dropped back at the top of the page. At the ends go() already scrolls nowhere.
The keyboard and screen readers
A scrolling box can only be scrolled from the keyboard if it can take focus, which is what tabindex="0" gives it. Focused, the arrow keys move it one snap position at a time in all three engines. Once it can take focus it needs a name, so role="region" and aria-label make it a landmark called “Projects” instead of an unnamed group. If the slides hold links, tabbing to one scrolls the row until the link is in view, and the row still comes to rest on a snap position rather than between slides. Safari skips links on Tab unless the reader turns that on; Option-Tab reaches them.
Hiding the scrollbar is fine once the buttons exist, and not before: for someone with a mouse without a sideways wheel, the scrollbar is the only way along.
/* Only with the buttons below: for a mouse without a
sideways wheel, they are the way through. */
.carousel {
scrollbar-width: none;
}scrollbar-width works in Chromium, Firefox and Safari 18.2 and later; older Safari keeps the scrollbar, which does no harm. The row still scrolls by touch, trackpad, keyboard and buttons.
Full-height sections on the page
To snap the page itself, the snap type goes on html, the element that scrolls the page, not on body. Each section aligns by its start and is at least as tall as the window:
html {
scroll-snap-type: y mandatory;
}
.panel {
min-height: 100vh; /* for browsers without svh */
min-height: 100svh;
scroll-snap-align: start;
}
/* Under mandatory, what has no snap position is skipped:
give the footer one, or it never fully comes into view. */
footer {
scroll-snap-align: end;
}On phones, 100vh is the height with the browser’s toolbars hidden. While they show, a section that tall is cut off at the bottom, and with every section snapped to its top, the cut part is the part nobody sees. 100svh is the height with the toolbars showing, so the whole section fits. 100dvh follows the toolbars as they slide in and out, which resizes every section, and moves every snap position, in the middle of a scroll. Desktop browsers have no toolbars that move, so there all three are the same.
min-height rather than height lets a section with more in it grow. As above, a tall section scrolls through normally. The footer gets scroll-snap-align: end, so the bottom of the page is a snap position too and the End key reaches it.
For a section that stays put while the page scrolls past, rather than a scroll that stops at each one, see the scroll pin generator; for a section that moves sideways as the page scrolls down, the horizontal scroll generator.
When CSS is not enough: scrollSnap
CSS scroll snap uses the browser’s own motion, and no property sets its speed or its curve. svg-scroll-draw’s scrollSnap snaps with a script instead: 100 ms after the last scroll event it glides to the next or previous section if the scroll passed a threshold of the current one, and back if not, over a duration and easing you choose (by default 600 ms, 'ease-in-out', threshold 0.3). It calls onSnap(index) after each snap in every browser, and snapTo(index) drives it from a menu:
<nav class="dots" aria-label="Sections">
<a href="#intro" aria-current="true">Intro</a>
<a href="#features">Features</a>
<a href="#pricing">Pricing</a>
</nav>
<section class="panel" id="intro">…</section>
<section class="panel" id="features">…</section>
<section class="panel" id="pricing">…</section>import { scrollSnap } from 'svg-scroll-draw/snap';
const links = [...document.querySelectorAll('.dots a')];
// No scroll-snap-type on html: scrollSnap does the snapping.
const snap = scrollSnap('.panel', {
duration: 800,
easing: 'ease-out',
threshold: 0.2,
onSnap: (index) => {
links.forEach((link, i) => {
if (i === index) link.setAttribute('aria-current', 'true');
else link.removeAttribute('aria-current');
});
},
});
links.forEach((link, i) => link.addEventListener('click', (event) => {
event.preventDefault();
snap.snapTo(i);
}));The panels keep their min-height: 100svh but lose scroll-snap-type: with both, the browser would pull toward its snap positions while the script glides toward its own. For readers who ask for reduced motion it jumps straight to the section instead of gliding, and still snaps; that follows the setting live, without a reload.
It lines up each section’s start edge on one axis, in the window or in a box you pass as scrollContainer, and it moves one section per gesture. For slides narrower than the box, or centre alignment, CSS is the better tool. The generator has a scrollSnap mode that writes this call for the same layouts.
— / next
Swipe it, then copy it
Pick the axis, mandatory or proximity, the alignment, padding and sizes, swipe the preview, and copy the HTML, CSS and button script.
Star on GitHubFound this useful? A star helps others find it.