A scroll-snap track plus the new pseudo-elements replaced a 14KB carousel dependency in my UI.
scroll-marker-group and ::scroll-marker render the dot navigation with no script and free keyboard support.
::scroll-button() draws working previous and next buttons that disable themselves at each end.
Numbered markers, peeking layouts, and an @supports query round out a fallback-safe carousel.
I pulled a 14KB carousel dependency out of a storefront last week and replaced it with roughly 40 lines of CSS. No JavaScript, no hydration step, no layout shift while a script boots. The browser now owns the dots, the arrows, and the keyboard handling, and it does all three more accessibly than the plugin did. Here are the five patterns I used, built on CSS features that shipped across 2025 and 2026.
Pattern 1: The Scroll-Snap Track That Does the Heavy Lifting
Every carousel should start as a scrollable, snapping track. This part has worked for years, and it is the accessible default: a region you can swipe on touch, scroll with a trackpad, and tab into with a keyboard.
.carousel {
display: flex;
overflow-x: auto;
scroll-snap-type: x mandatory;
scroll-behavior: smooth;
}
.carousel > * {
flex: 0 0 100%;
scroll-snap-align: center;
}
scroll-snap-type: x mandatory forces each item to settle into place. Switch to proximity if you want snapping only when the user lands close to an edge, which feels better for long, free-scrolling galleries. scroll-snap-align: center decides where each slide parks. That is a complete, usable carousel already, before a single control, and it degrades to a plain scroll region on any browser made in the last decade. The 2026 pieces all attach to this same track, which is why getting the foundation right matters more than the decoration. If scroll behaviour is your thing, I went deeper on it in scroll-driven animations.
One structural note before the controls: the marker and button pseudo-elements attach to the scroll container and its direct children, so mark up the slides as a list. A `
of - slides gives you the right semantics and the right elements for ::scroll-marker` to hang off, and the markup still means something when the CSS is stripped or fails to load.
Pattern 2: Dot Navigation With scroll-marker-group
The dots used to be a JavaScript job: track the active index, render a button per slide, and wire each click back to a scroll. Now the scroll container generates them for you.
.carousel { scroll-marker-group: after; }
.carousel > li::scroll-marker {
content: "";
width: 8px;
height: 8px;
border-radius: 9999px;
background: #2a2a2c;
}
.carousel > li::scroll-marker:target-current {
background: #e3fc02;
}
.carousel::scroll-marker-group {
display: flex;
gap: 8px;
justify-content: center;
}
scroll-marker-group: after tells the container to emit a group of markers after the content. Each child gets a ::scroll-marker pseudo-element, and :target-current matches whichever marker maps to the slide currently in view, so the active dot lights up with no state to manage. Clicking a marker scrolls to its item. Arrow keys move between markers as a single focus group, and the group itself is one tab stop, which is exactly the keyboard model the accessibility guidelines ask for. There is no index variable, no event listener, and no ARIA to hand-roll, because the browser already knows which slide is showing.
Placement is a one-line decision. scroll-marker-group: after drops the dots below the track, before puts them above, and you style ::scroll-marker-group like any flex container to position and space them. Because the group is a single focus stop with arrow-key movement between markers, you get the keyboard pattern assistive tech expects, which is the part hand-rolled carousels almost always get wrong.
Pattern 3: Previous and Next Buttons With ::scroll-button()
::scroll-button() generates real buttons bound to the scroll position. You pass a direction, and the browser disables the button automatically when scrolling that way is no longer possible. That single behaviour kills the most common carousel bug, where the next arrow stays clickable past the final slide and the track jiggles against its edge.
.carousel::scroll-button(inline-start) {
content: "\2039" / "Previous slide";
}
.carousel::scroll-button(inline-end) {
content: "\203A" / "Next slide";
}
.carousel::scroll-button(*):disabled {
opacity: 0.4;
cursor: default;
}
The text after the slash in content is the accessible name, so a screen reader announces "Previous slide" rather than a stray glyph. Logical directions like inline-start and inline-end follow the writing mode, so a right-to-left layout flips the arrows correctly with no extra code. These buttons are focusable and keyboard-operable the moment they exist, and the :disabled state is real, not a class you toggle.
If you would rather hide the arrows at the ends than dim them, target the disabled state with display: none instead of an opacity change. Either way the behaviour belongs to the browser, so it stays correct when slides are added or removed at runtime, which is exactly the situation where the old index-tracking code used to fall out of sync.
Pattern 4: Numbered and Thumbnail Markers
The default dot is a starting point, not a ceiling. Because ::scroll-marker accepts a content value, you can number the markers or drop a thumbnail into each one. Numbered markers use a counter on the track.
.carousel { counter-reset: slide; }
.carousel > li { counter-increment: slide; }
.carousel > li::scroll-marker {
content: counter(slide);
display: grid;
place-items: center;
width: 24px;
height: 24px;
border-radius: 9999px;
font-size: 12px;
color: #f5f5f7;
background: #2a2a2c;
}
For a thumbnail strip, set a background-image on each marker instead of a counter and size it to taste. This is the pattern that used to demand the most JavaScript, since a custom thumbnail navigator meant syncing two scrollers by hand. Here the markers stay in lockstep with the slides for free, and :target-current still highlights the active one. It is the clearest example of the platform absorbing a whole component category.
Pattern 5: Peeking Layouts That Hint at More
A carousel reads better when the next slide peeks in from the edge, because the partial slide is the affordance that tells people the row scrolls. You get it by sizing slides under 100% and adding scroll padding so the snapped item does not hug the wall.
.carousel {
scroll-padding-inline: 24px;
gap: 16px;
}
.carousel > * {
flex: 0 0 80%;
scroll-snap-align: center;
}
Now each slide takes 80% of the track and the neighbours show at both edges. scroll-padding-inline keeps the snap point inset so the active slide sits centred with breathing room rather than flush against the container. Pair it with the markers and buttons from the earlier patterns and you have a modern, app-like carousel that never blocked the main thread to render.
Progressive Enhancement and Accessibility
Wrap the enhanced styling in a feature query so unsupported browsers fall back to the plain scrolling track instead of a broken control.
@supports selector(::scroll-marker) {
/* dot, number, and button styling lives here */
}
@media (prefers-reduced-motion: reduce) {
.carousel { scroll-behavior: auto; }
}
The accessibility story is the real headline. A JavaScript carousel is usually a stack of divs with hand-written ARIA that drifts out of sync the moment state changes. These pseudo-elements are genuine controls the browser exposes to assistive technology and the keyboard by default, so you inherit focus order, announcements, and disabled states without writing any of it. The same instinct shows up in anchor positioning, where the platform absorbs work that used to need a library.
Browser Support and How I Migrated
Be honest about support and you can ship this today. The carousel pseudo-elements (::scroll-marker, scroll-marker-group, and ::scroll-button()) landed in Chrome in 2025 and are rolling through the other engines, while scroll-snap itself is universal and has been for years. That split is the whole reason the fallback is safe: a browser that does not understand the markers still renders a clean, swipeable, snapping track. The decoration degrades, the function does not.
The migration took an afternoon. I deleted the library import and its initialisation call, swapped the slider markup for a plain `
`, moved the dots and arrows into the stylesheet, and removed the resize handler the plugin needed to recompute its layout. The result is less JavaScript on the wire, no layout shift while a script boots, and a carousel that works before hydration finishes. Before shipping, tab through it once to confirm focus order and test it with reduced motion turned on, then delete the dependency for good.
Bottom Line
A scroll-snap track, ::scroll-marker for the dots, ::scroll-button() for the arrows, plus numbered markers and a peeking layout cover the vast majority of carousels with no script and noticeably better accessibility than the plugins they replace. I now reach for JavaScript only when I need autoplay, an infinite loop, or analytics on slide views, and even then it sits on top of the native track rather than replacing it.
Strip the dependency, keep the snap, and let the browser do the rest. For more patterns where CSS quietly retired a library, browse the Lab.
Top comments (0)