Robin van der Vleuten

A CSS-only carousel slider that still feels native

You're building a product page, and somebody asks for a carousel.

The request sounds harmless. A few cards. Some dots. Maybe previous and next buttons if there is time. Then the small details arrive: it should swipe on touch screens, work with a keyboard, land cleanly on each slide, and not drag a dependency into a page that otherwise does not need JavaScript.

That is where carousel code tends to get silly.

I have written the version with hidden radio inputs. I have written the version that keeps an activeIndex in JavaScript and nudges a strip of slides around with transforms. The worst version had the dots, buttons, swipe handling, resize handling, and animation timing all disagreeing with each other in tiny ways.

Most of that work was me fighting the browser.

Let the browser do the scrolling

A carousel is mostly a horizontal scroll area with strong opinions about where scrolling should stop. CSS scroll snap is built for exactly that kind of thing.

The browser already knows how to handle trackpads, touch screens, keyboard scrolling, reduced motion settings, and the odd input device you forgot existed. Use that.

Start with the markup:

html
<section class="carousel" aria-labelledby="carousel-title">
<h2 id="carousel-title">Featured projects</h2>
<div class="carousel__track" aria-label="Project slides" tabindex="0">
<article class="carousel__slide" id="project-1">
<h3>Dashboard cleanup</h3>
<p>Small layout changes that made a busy admin page easier to scan.</p>
</article>
<article class="carousel__slide" id="project-2">
<h3>Checkout recovery</h3>
<p>A quieter payment flow with fewer dead ends when a card fails.</p>
</article>
<article class="carousel__slide" id="project-3">
<h3>Search tuning</h3>
<p>Better defaults for people who do not know the exact term yet.</p>
</article>
</div>
<nav class="carousel__nav" aria-label="Choose a project">
<a href="#project-1" aria-label="Show project 1"></a>
<a href="#project-2" aria-label="Show project 2"></a>
<a href="#project-3" aria-label="Show project 3"></a>
</nav>
</section>

Nothing clever yet. The slides are real content. The dots are normal links. Each link points at the id of a slide, which means the browser can move that slide into view without a click handler.

That last bit is doing more work than it looks like. This is ordinary document behavior, not a fake navigation system painted on top of a widget.

The CSS that turns it into a carousel

Now make the track scroll horizontally and tell each slide where it should settle.

css
.carousel {
max-width: 48rem;
}
.carousel__track {
display: flex;
gap: 1rem;
overflow-x: auto;
overscroll-behavior-x: contain;
scroll-behavior: smooth;
scroll-snap-type: x mandatory;
scrollbar-width: none;
}
.carousel__track::-webkit-scrollbar {
display: none;
}
.carousel__slide {
flex: 0 0 100%;
min-height: 16rem;
scroll-snap-align: start;
}
.carousel__nav {
display: flex;
gap: 0.5rem;
justify-content: center;
margin-block-start: 1rem;
}
.carousel__nav a {
block-size: 0.75rem;
border: 1px solid currentColor;
border-radius: 999px;
inline-size: 0.75rem;
}
@media (prefers-reduced-motion: reduce) {
.carousel__track {
scroll-behavior: auto;
}
}

scroll-snap-type: x mandatory tells the track to snap along the horizontal axis. Each slide opts in with scroll-snap-align: start, so the left edge of the slide becomes the resting point.

The scroll-behavior property only affects scrolling triggered by navigation or CSSOM APIs, which is exactly what happens when you click one of those fragment links. User scrolling still feels like user scrolling.

The track is focusable with tabindex="0", so keyboard users can put focus on it and scroll through the slides with the keys their browser already supports.

Making peeked slides

Full-width slides are fine for a hero carousel. Card carousels often feel better when the next item peeks in from the side.

Change the slide basis and add some padding to the track:

css
.carousel__track {
display: flex;
gap: 1rem;
overflow-x: auto;
padding-inline: 1rem;
scroll-padding-inline: 1rem;
scroll-snap-type: x mandatory;
}
.carousel__slide {
flex: 0 0 min(80%, 28rem);
scroll-snap-align: start;
}

scroll-padding-inline is the easy-to-miss part. It tells the snap container that the useful viewing area starts after the padding, so the first slide does not snap awkwardly underneath the edge.

That gives you the familiar "one card and a bit of the next one" layout without calculating transforms.

What about previous and next buttons?

This is where the CSS-only promise gets a bit uncomfortable.

Dots are easy because every dot can point at a known slide. Previous and next buttons need to know where you are now. CSS can style the :target slide after a link click, but it does not know the current scroll position after a swipe, a trackpad gesture, or keyboard scrolling.

So you have two reasonable options:

  • Keep it CSS-only and use direct slide links. This works well for small carousels where jumping to slide 1, 2, or 3 is clear enough.
  • Add a tiny layer of JavaScript for relative controls. Let CSS handle the layout and snapping. Use JavaScript for the parts that actually need state, like "go to the next slide" or "mark the current dot."

That second option is not cheating. It keeps the awkward cross-device scrolling work in the browser, and the state you own stays small.

A complete version

Here is the whole thing together, with just enough styling to make the example readable:

html
<section class="carousel" aria-labelledby="carousel-title">
<h2 id="carousel-title">Featured projects</h2>
<div class="carousel__track" aria-label="Project slides" tabindex="0">
<article class="carousel__slide" id="project-1">
<p class="carousel__eyebrow">01</p>
<h3>Dashboard cleanup</h3>
<p>Small layout changes that made a busy admin page easier to scan.</p>
</article>
<article class="carousel__slide" id="project-2">
<p class="carousel__eyebrow">02</p>
<h3>Checkout recovery</h3>
<p>A quieter payment flow with fewer dead ends when a card fails.</p>
</article>
<article class="carousel__slide" id="project-3">
<p class="carousel__eyebrow">03</p>
<h3>Search tuning</h3>
<p>Better defaults for people who do not know the exact term yet.</p>
</article>
</div>
<nav class="carousel__nav" aria-label="Choose a project">
<a href="#project-1" aria-label="Show project 1"></a>
<a href="#project-2" aria-label="Show project 2"></a>
<a href="#project-3" aria-label="Show project 3"></a>
</nav>
</section>
css
.carousel {
color: #1f2937;
max-width: 48rem;
}
.carousel__track {
display: flex;
gap: 1rem;
overflow-x: auto;
overscroll-behavior-x: contain;
padding-inline: 1rem;
scroll-behavior: smooth;
scroll-padding-inline: 1rem;
scroll-snap-type: x mandatory;
scrollbar-width: none;
}
.carousel__track:focus-visible {
outline: 3px solid #2563eb;
outline-offset: 0.25rem;
}
.carousel__track::-webkit-scrollbar {
display: none;
}
.carousel__slide {
background: #f8fafc;
border: 1px solid #cbd5e1;
border-radius: 0.75rem;
flex: 0 0 min(80%, 28rem);
min-height: 16rem;
padding: 1.5rem;
scroll-snap-align: start;
}
.carousel__eyebrow {
color: #64748b;
font-size: 0.875rem;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.carousel__nav {
display: flex;
gap: 0.5rem;
justify-content: center;
margin-block-start: 1rem;
}
.carousel__nav a {
block-size: 0.75rem;
border: 1px solid currentColor;
border-radius: 999px;
color: inherit;
inline-size: 0.75rem;
}
.carousel__nav a:hover,
.carousel__nav a:focus-visible {
background: currentColor;
}
@media (prefers-reduced-motion: reduce) {
.carousel__track {
scroll-behavior: auto;
}
}

Notice what is missing. There are no cloned slides, no transform calculations, and no timer trying to move the carousel while somebody is halfway through reading the card.

It is just a scroll container with snap points.

When this is enough

Use this when the carousel supports the page rather than becoming the page:

  • Product cards. A row of related products can scroll, snap, and link directly to each item.
  • Testimonials. Direct dots are usually enough because the reader is browsing, not completing a task.
  • Small media galleries. A handful of images works well as long as each slide still has useful alt text and surrounding context.
  • Editorial teasers. Cards that remain readable as ordinary HTML age better than a custom widget.

I would not use this for a complex gallery with thumbnails, zooming, lazy video playback, analytics, and synchronized controls. At that point you have application state, and pretending otherwise only makes the CSS stranger.

The practical lesson

The best CSS-only carousel is not a trick. It is a scroll area with enough CSS to make the browser's native behavior line up with the design.

That is a much smaller thing to keep in your head six months later.

If you need more, add JavaScript where the need appears. But start with scroll snap, fragment links through stable id attributes, and a normal overflow container. You get farther than you might expect.