- Modern CSS scroll snap turns simple scrollers into native-feeling carousels without JavaScript.
- Experimental ::scroll-button and ::scroll-marker provide built-in navigation and accessibility hooks.
- Well-designed fallbacks, ARIA roles and motion-respecting styles make CSS carousels production ready.
- Platform evolution (bring-your-own elements, cyclic scrolling) will further reduce the need for JS libraries.
Building carousels without a single line of JavaScript is not science fiction anymore; modern CSS gives you everything you need to create smooth, touch-friendly sliders using the CSS touch-action property that feel native in the browser. From scroll snapping to experimental pseudo-elements that auto-generate navigation controls, you can deliver full‑featured components while keeping your bundle lean and your life simpler.
If you’ve ever wrestled with heavy JS carousel libraries, hydration issues or accessibility quirks, CSS‑only carousels are a breath of fresh air. With the latest specs (like CSS Overflow Level 5) and well-established features such as scroll-snap-type, it’s now realistic to ship production‑ready carousels that are fast, keyboard accessible and resilient when JavaScript is disabled.
Why modern CSS makes JavaScript carousels optional
For years, the default answer to “how do I build a carousel?” was “grab a JS library”. Swiper, Slick, Glide, Bootstrap’s carousel and many others solved the problem by juggling event listeners, timers, resize observers and ARIA roles for you—but they also stuffed extra kilobytes into your bundle and introduced another dependency to maintain.
Today, CSS has quietly caught up to most common carousel use cases. With a handful of properties, you can turn a normal horizontal scroller into a paginated, snap‑to‑slide experience that works with mouse wheel, trackpad, keyboard arrows and touch swipes. No listeners, no reflows caused by JS logic, and no race conditions with hydration in frameworks.
The basic idea is simple: you treat your carousel as a scrollable region, not as a magic component that teleports between slides. CSS then “helps” the scroll with snap points, smooth transitions and, in the newest spec, built‑in buttons and markers that the browser injects into the DOM as real interactive elements.
This shift has big consequences for performance and resilience. A CSS carousel still works perfectly when JS is blocked, fails to load or is disabled by the user. The browser’s own scrolling engine does most of the heavy lifting, which is highly optimized in every major engine and tuned for low‑power devices and accessibility tools.
On top of that, CSS features like :has(), grid layout and scroll-driven animations plug into the same scroll area, letting you build sophisticated slide indicators, content reveals or parallax without merging three different systems (your code, the library’s logic and the browser scroll) in fragile ways.
Core building block: Scroll Snap for CSS-only carousels
At the heart of a CSS-only carousel is the Scroll Snap module, which lets you define “magnetic” points that the scroll position should lock onto after a user stops scrolling. Instead of landing halfway between two slides, the view snaps to the nearest one, giving that classic slider feel.
The HTML structure can be minimal: a container that scrolls horizontally, plus a series of slide elements inside it. You don’t even need to give each image or card its own class if your markup is consistent—though naming your items often helps for styling and accessibility:
Example structure for a basic image carousel could be conceptualized as a wrapper like <ul class="carousel"> with multiple <li> items, each containing an image or card. The magic lives in the CSS:
You define the container as a horizontal flex layout with scrolling enabled and specify that it should snap along the x axis:
.carousel { display: flex; overflow-x: auto; scroll-snap-type: x mandatory; scroll-behavior: smooth; }
Each individual slide then exposes a snap position, usually at the center or start of the viewport, and sets a fixed basis so one or several appear per view:
.carousel-item { flex: 0 0 100%; scroll-snap-align: center; }
This pattern already gives you a surprisingly complete carousel: users can swipe on mobile, scroll with a mouse or trackpad, and the viewport will always settle on the closest slide. There’s no JS watching for scroll events or manually adjusting positions.
To create multi‑item carousels (like product strips), you only need to tweak the flex basis. For example, you might show three items per view with a bit of spacing by using something like flex: 0 0 calc(33.333% - 1rem) plus side margins. Scroll Snap doesn’t care whether each “page” is a single full‑width slide or a group of smaller cards.
Properties such as object-fit: cover are useful when working with raw images, ensuring that they fill their slide area without stretching. Combined with a fixed aspect ratio, you can get image carousels that stay visually consistent on any screen size.
From plain scrollers to native-feeling carousels
A bare scroll-snap carousel already feels good, but modern specs take it further, especially in Chrome 135+ where the CSS Overflow Level 5 draft begins to surface as real features. The spec treats a carousel as a scroll area with optional, browser‑generated UI around it.
In that model, a “carousel” equals: one scroller plus up to two sets of affordances—scroll buttons and scroll markers. Both are created by CSS pseudo-elements, but the browser emits actual DOM siblings next to your scroller so they behave like native controls.
Crucially, the browser wires up semantics, focus order and state management for you. The injected elements have proper roles, they’re tabbable in a sensible order, and they reflect the current scroll position. That means replicating the same level of accessibility with a hand‑rolled JS library is non‑trivial; you’d need ARIA attributes, focusgroup behavior, keyboard navigation and live region announcements all working together.
Even if you’re not ready to rely on the experimental parts yet, this direction is important. It signals that carousels, tabs, scrollspy components and similar UI patterns are being elevated to “first‑class citizens” in CSS, which will steadily reduce the need for custom JavaScript glue code.
The end result is a more robust baseline for all users—including people who disable JS, rely on assistive tech, have low-power devices or browse under flaky network conditions. Native scroll plus CSS styling trump script-heavy widgets in all those scenarios.
Adding scroll buttons with ::scroll-button()
On some platforms you already see small arrows next to the scrollbars, but the new ::scroll-button() pseudo-element goes a step further and lets CSS define dedicated “previous/next” buttons for any scrollable area—including carousels.
These CSS-defined scroll buttons behave differently from old-school scrollbar arrows. Instead of nudging the content in tiny increments, they jump roughly 85% of the visible area per click. In a full‑width, snap‑aligned carousel, that feels almost exactly like a per‑slide navigation button.
If you truly need an exact one‑item movement, you can combine scroll snapping options. A typical recipe is scroll-snap-type: x mandatory on the container and scroll-snap-stop: always on each snap child. That way, one press of the scroll button effectively takes you from one card to the next.
From a syntax perspective, adding scroll buttons looks a lot like styling other pseudo-elements. You pick the scrollable element (say, .carousel) and target ::scroll-button(left) or ::scroll-button(right) to represent back and forward controls. A minimal example:
.carousel::scroll-button(left) { content: "⬅" / "Scroll left"; }
.carousel::scroll-button(right) { content: "⮕" / "Scroll right"; }
The slash-separated content value allows you to provide both a visual and an accessible label. The browser then creates real <button> nodes as siblings of your scroll area and places your content inside them. You remain free to position and style them as you wish, just like any other element—using absolute positioning, CSS grid or even anchor positioning.
Focus styling still matters here. For instance, applying a rule such as .carousel::scroll-button(*):focus-visible { outline-offset: 5px; } ensures that when users tab to these buttons, they see a clear focus ring offset from the element’s border, preserving keyboard usability and WCAG compliance.
Under the hood, the browser keeps track of button state and available scroll range. That means you don’t have to worry about disabling the “previous” button when you’re at the first slide or the “next” button at the end; the platform can handle those conditions in a standardized way.
Scroll markers with ::scroll-marker() for bullet or label navigation
Visual markers—dots or thumbnails under the carousel—are almost expected by users nowadays. The new ::scroll-marker() pseudo-element lets you generate those indicators strictly with CSS, each one linked to a specific item in the scrollable list.
Unlike the scrollbar thumb, scroll markers represent semantic points of interest. Each marker corresponds not just to a position in pixels, but to an actual child element, making it ideal for scenarios like seasons in a TV series, product categories, or logical chapters in a story rather than every single frame.
The markers show up as valid <a> elements, giving you two important features for free: in-page navigation and correct semantics for screen readers. They behave much like anchor links, but with some enhancements tailored to carousel use.
Among those enhancements is the :target-current pseudo-class, which matches when the corresponding item is currently snapped into view or otherwise considered “the active slide”. You can use it to highlight the active marker with a different background or border.
To hook this up, you first define where the group of markers should live using the scroll-marker-group property on the container, typically before or after the scroller. For example, scroll-marker-group: after; would place the markers after the carousel region in the DOM.
Then you target the individual children that should generate markers. If your slides are <li> elements, something like .carousel > li::scroll-marker will do. A common pattern is to create empty dots:
.carousel { scroll-marker-group: after; }
.carousel > li::scroll-marker { content: " "; }
.carousel > li::scroll-marker:target-current { background: var(--accent); }
The browser takes care of generating the <a> markers, grouping them into a dedicated ::scroll-marker-group container, and exposing them as keyboard-navigable elements that behave like a focusgroup. Screen readers can present them similarly to a tablist: you move focus between markers and activate them to jump to a particular slide.
Markers aren’t limited to dots. You can set content to numbers ("1", "2", etc.), human-readable labels like “Season 1” or “Chapter 3”, or even inline images to create thumbnail galleries for photo-heavy layouts such as e‑commerce product galleries.
Combining buttons, markers, and fallback strategies
When you mix scroll buttons and markers on top of a scroll-snap carousel, you get a component that feels indistinguishable from a JS solution, but is easier to maintain and ships fewer moving parts. Users can click arrows, tap bullets, swipe on touchscreens or use keyboard navigation—all with consistent behavior.
This combo also addresses common performance and UX headaches. Because no script coordinates slide changes, you don’t get layout thrashing from manual scrollLeft adjustments or timers firing at awkward times. CLS (Cumulative Layout Shift) is reduced since the DOM tree is ready from the start and you’re not injecting or measuring elements late in the lifecycle.
However, there’s a catch: ::scroll-button and ::scroll-marker are still experimental and currently only land in Chrome 135+ behind experimental flags. That means you absolutely must design graceful fallbacks if you plan to use them on production sites.
Feature detection via @supports is your best ally here. You can render a traditional navigation bar—links to #slide1, #slide2 and so on—by default, then hide it when the native controls are available. A rough conceptual pattern looks like this:
.carousel-nav { display: flex; gap: 0.5rem; }
@supports (scroll-button-inline: both) { .carousel-nav { display: none; } }
In the HTML, a simple nav with anchor links is enough: each slide gets an id, and the nav points to those IDs. Jumping to an anchor inside a scroll-snap container integrates nicely with the snapping behavior, so the slide snaps neatly after the jump.
This means you can enjoy the benefits of the experimental pseudo-elements in supporting browsers—full native buttons, markers, and scroll state hooks—while still offering a fully functional experience elsewhere. No user gets stuck with an unusable slider.
On top of that, all this still works when JavaScript is disabled, because you rely purely on HTML anchors, CSS scroll behavior and the browser’s scrolling engine. For many UX patterns—galleries, timelines, step‑by‑step showcases—that’s more than enough.
CSS-only autoplay carousels with keyframe animations
One common feature people expect from carousels is autoplay—slides advancing automatically after a few seconds. While this is often implemented with JavaScript timers, you can also approximate this behavior using pure CSS animations.
The trick is to animate the transform of a flex row that contains all slides. For a fixed number of slides, you define a keyframe timeline where the track stays at each slide position for a while, then jumps to the next. Something like:
@keyframes slide { 0%, 20% { transform: translateX(0); } 25%, 45% { transform: translateX(-100%); } 50%, 70% { transform: translateX(-200%); } 75%, 95% { transform: translateX(-300%); } 100% { transform: translateX(0); } }
Then you apply this animation to the wrapper that holds your slide elements, e.g. .autoplay-carousel { display: flex; animation: slide 12s infinite; }. The percentage ranges define “dwell” times on each slide so the content is readable before moving on.
It’s crucial to add a way for users to pause autoplay. A simple but effective pattern is pausing on hover with .autoplay-carousel:hover { animation-play-state: paused; }, so desktop users can stop the motion when they interact with the content.
Accessibility considerations go even further with motion preferences. Respecting prefers-reduced-motion is considered best practice, so you can disable the animation entirely for users who prefer less motion:
@media (prefers-reduced-motion: reduce) { .autoplay-carousel { animation: none; } }
While CSS autoplay solutions can’t easily handle all edge cases that JS can—like truly dynamic slide counts or complex user-driven pause/resume logic—they’re surprisingly solid for simple, static carousels when you want to avoid scripting altogether.
Comparing CSS-only and JavaScript carousels
Deciding between CSS and JavaScript for a carousel is not an all-or-nothing proposition; it depends on your requirements, audience and maintenance budget. But the balance has shifted heavily in favor of CSS for many real-world scenarios.
From a performance perspective, CSS carousels are clear winners. There’s no parsing or executing of library code, no event listeners on every scroll interaction, and no runtime layout recalculations triggered by scripted adjustments. The browser’s scroll engine is written in highly optimized native code and tuned for everything from high‑end desktops to low‑power phones.
In terms of accessibility, the new CSS carousel features raise the bar significantly. Browser-provided scroll buttons and markers come pre-wired with ARIA roles, keyboard behavior and announcements that would otherwise have to be meticulously recreated in JavaScript. Even without those, a scroll-snap carousel can still be made accessible with roles and labels.
On the flip side, JavaScript remains useful for very advanced patterns. If you need elaborate autoplay logic, analytics hooks, custom physics, or to synchronize multiple carousels with shared state (e.g., main image plus thumbnail row plus external pagination), script-driven approaches can still be more flexible.
However, the key insight is that many “everyday” carousels don’t actually need this level of complexity. A simple product gallery, a testimonial slider or a “featured posts” strip can usually be implemented with scroll snap, a few CSS rules and optional native controls—saving you from pulling in a 50KB dependency just to move between three slides.
Another consideration is resilience when JavaScript fails. CSS-only carousels keep working perfectly if JS is blocked by a corporate proxy, broken by an ad blocker, or simply not yet loaded when the user lands on the page. That kind of graceful degradation is hard to achieve with libraries that depend on an initialization phase after page load.
Accessibility best practices for CSS carousels
Even if the browser helps with some accessibility aspects, you still play a crucial role in making your CSS carousel usable for everyone. Semantics, focus management and motion sensitivity should all be at the front of your mind.
First, give the carousel an appropriate landmark. Wrapping it in an element with role="region" and a descriptive aria-label like “Product gallery” or “Featured articles” makes it easier for screen reader users to find and understand.
Each slide can carry an accessible label too, for instance via aria-label stating “Slide 1 of 3”, “Slide 2 of 3”, and so on. That way, when a user lands on a slide, they get immediate context about their position in the sequence.
Don’t forget visible focus indicators. Any interactive element—whether it’s a ::scroll-button, a ::scroll-marker-generated link, or a manual anchor in your fallback nav—needs a clear focus style that passes contrast guidelines and isn’t removed by global resets.
If you use autoplay or scroll-driven animations, respect motion preferences. Using prefers-reduced-motion to disable or simplify animations is not optional if you care about users prone to motion sickness, and it’s easy enough to wrap the more intense effects in a media query.
Lastly, consider touch target sizes. Whether you’re styling native scroll markers or your own nav links, keep interactive areas at least around 44×44 CSS pixels. That usually means generous padding and spacing rather than tiny circles packed tightly together.
Real-world workflows, tools, and future directions
To make experimenting with CSS carousels easier, some teams have created live configurator tools where you can flip switches—enable scroll buttons, toggle markers, adjust snap modes—and immediately see the updated CSS required for that configuration. These playgrounds are fantastic for learning by tinkering.
Alongside configurators, curated galleries of CSS-only carousel demos showcase what’s possible. You’ll find patterns like tabbed interfaces, scrollspy navigation, step-based slides, thumbnail galleries and more, all sharing the same foundation of scroll areas plus snapping and optional controls.
Many of these demo sites intentionally ship with zero JavaScript to prove the point: a rich, interactive-feeling UI is entirely achievable with HTML and CSS alone. They often expose their utilities through @layer declarations, so you can inspect and cherry-pick styles that fit your own design system.
Looking ahead, the CSS platform is already working on two important evolution paths. The first is “bring your own elements,” where instead of relying on browser-generated buttons and markers, you’ll be able to plug in your own <a> and <button> markup, while still benefiting from the underlying scroll logic.
This would open the door to branded, richly styled controls built with utility frameworks like Tailwind, while keeping the semantics and behavior governed by the platform. You’d essentially decorate the UI but let the browser remain the brains of the operation.
The second big area is cyclical or infinite scrolling. Many carousels today “wrap” when you reach the end, looping seamlessly back to the first slide. Implementing this cleanly is tricky, often involving duplicate content or complex logic. Platform-level support for cyclic scrolling would let browsers handle the wraparound behavior more elegantly and efficiently.
As these features mature and spread beyond Chrome into other engines, the gap between what a “native CSS carousel” can do and what heavy-weight JavaScript libraries offer will continue to narrow, making script-free approaches the default starting point rather than an edge case experiment.
All in all, modern CSS gives you every reason to rethink how you build carousels. Starting with a simple scroll-snap layout, layering in experimental buttons and markers where supported, and sprinkling accessibility best practices throughout, you can deliver responsive, smooth, and robust sliders without reaching for a JS dependency. For many projects, that means faster pages, less code to maintain, and a more resilient experience for every user.