Why a CSS-only carousel?
JavaScript carousel libraries — Swiper.js, Slick, Flickity, Embla — have been staples of web development for years. They provide navigation buttons, dot indicators, touch support, and snap behavior. But they also add 30-100 KB of JavaScript, require initialization, and often need careful ARIA work to be accessible.
CSS now provides native pseudo-elements that generate the same UI affordances: ::scroll-button() for prev/next navigation and ::scroll-marker for dot indicators. These are browser-generated, focusable, keyboard-accessible, and auto-disabled at scroll boundaries. Zero JavaScript required.
Step 1: Set up the scroll container
Start with a simple HTML list inside a scrollable container:
<ul class="carousel">
<li>Slide 1</li>
<li>Slide 2</li>
<li>Slide 3</li>
<li>Slide 4</li>
</ul> Make it horizontally scrollable with scroll snapping:
.carousel {
display: flex;
overflow-x: auto;
scroll-snap-type: x mandatory;
gap: 16px;
}
.carousel li {
flex: 0 0 100%;
scroll-snap-align: center;
} Step 2: Add navigation buttons
The ::scroll-button() pseudo-element creates browser-provided scroll buttons on a scroll container. They behave like regular buttons: focusable, clickable, and automatically disabled when scrolling is no longer possible in a given direction.
.carousel::scroll-button(left) {
content: "←" / "Previous slide";
background: #fff;
border: 1px solid #e0e0e0;
border-radius: 50%;
width: 40px;
height: 40px;
}
.carousel::scroll-button(right) {
content: "→" / "Next slide";
/* same styles */
} When activated, each button scrolls the container by approximately 85% of its visible area. The second value after the / in the content property sets the accessible label.
Step 3: Add dot indicators
The ::scroll-marker pseudo-element represents a navigation marker for each item in the scroll container. These are grouped automatically and behave like anchor links, letting users jump directly to a specific slide.
.carousel {
scroll-marker-group: after;
}
.carousel li::scroll-marker {
content: '';
width: 12px;
height: 12px;
border-radius: 50%;
background: #ccc;
border: none;
} The scroll-marker-group: after property places the marker group after the carousel content. You can also use before to place it above.
Step 4: Style the active marker
The :target-current pseudo-class matches the marker whose corresponding item is currently scrolled into view:
.carousel li::scroll-marker:target-current {
background: #7c3aed;
transform: scale(1.2);
} This gives you the classic "active dot" indicator that updates automatically as the user scrolls. No JavaScript IntersectionObserver or scroll position calculation needed.
The complete carousel
Here's the entire CSS for a fully functional, accessible carousel:
.carousel {
display: flex;
overflow-x: auto;
scroll-snap-type: x mandatory;
gap: 16px;
scroll-marker-group: after;
}
.carousel li {
flex: 0 0 100%;
scroll-snap-align: center;
}
.carousel::scroll-button(left) {
content: "←" / "Previous";
}
.carousel::scroll-button(right) {
content: "→" / "Next";
}
.carousel li::scroll-marker {
content: '';
width: 12px; height: 12px;
border-radius: 50%;
background: #ccc;
}
.carousel li::scroll-marker:target-current {
background: #7c3aed;
} That's it. No JavaScript, no library, no ARIA attributes to manage. The browser handles keyboard navigation, focus management, scroll snapping, and active state tracking natively.
Browser support
::scroll-button() and ::scroll-marker are available in Chrome and Edge. Firefox and Safari implementations are in progress. For a progressive enhancement approach, wrap the scroll-button and scroll-marker styles in @supports (scroll-marker-group: after) and provide a basic scrollable fallback for unsupported browsers.