Create advanced CSS carousels with zero JavaScript

Última actualización: 05/24/2026
  • Modern CSS scroll-snap turns simple scrollers into smooth, paginated carousels without JavaScript.
  • New pseudo-elements like ::scroll-button() and ::scroll-marker() generate accessible carousel controls in pure CSS.
  • CSS-only strategies, combined with careful fallbacks and ARIA, deliver fast, resilient and inclusive carousels.
  • JavaScript becomes optional, reserved for edge cases like true infinite looping or complex stateful behaviour.

CSS carousel without JavaScript

Building carousels with nothing but modern CSS is no longer a pipe dream—it is a very real, production‑ready option that can often outperform classic JavaScript slider libraries. Thanks to features like scroll snapping, scroll-driven animations, experimental carousel pseudo-elements and robust accessibility primitives, you can ship lightweight, resilient carousels that still feel smooth, interactive and polished.

If you are tired of pulling in 50KB JavaScript bundles just to show a few slides, this guide walks you through what today’s CSS can do for you. We will break down the key concepts behind CSS-only carousels, how to wire them up with scroll-snap, when to lean on the new ::scroll-button() and ::scroll-marker() pseudo-elements, and how to sprinkle in autoplay or circular behaviour while still treating accessibility and performance as first‑class citizens.

Why CSS-only carousels are finally a serious option

For years, “carousel” was almost a synonym for “JavaScript plugin”. Solutions like Swiper, Glide or home‑grown sliders were the go-to approach: they handled navigation, state, keyboard support and touch gestures, but at the cost of extra weight, event listeners everywhere and a fair amount of DOM gymnastics.

Modern CSS has quietly closed most of that gap. With properties such as scroll-snap-type and scroll-snap-align, you can turn any horizontal or vertical scroller into a paginated experience. On top of that, the CSS Overflow Level 5 spec introduces browser‑generated scroll buttons and markers that behave like a fully featured carousel UI, but are declared entirely in CSS.

The big win is that the browser, not your JavaScript code, becomes responsible for core UX concerns: scrolling physics, keyboard navigation, focus order, and even ARIA roles for the carousel’s controls. In many cases it is genuinely hard to build something more accessible and better performing than what the platform now gives you out of the box.

Another major advantage of CSS carousels is resilience. If JavaScript fails to load, users with script blocked or disabled still get a perfectly usable scrollable set of items. There is no hydration, no layout shift introduced by late‑loading code, and far fewer moving parts that can go wrong over time.

Core building block: a scrollable container with Scroll Snap

Every CSS-only carousel starts life as a regular scrollable container. Think of a simple list of slides that can be scrolled horizontally. From there, you add scroll snapping so the viewport naturally locks onto each slide.

A minimal HTML structure for a horizontal carousel is intentionally simple: a wrapper element and a series of slide items inside. You might use a ul with li elements, or a div with child divs—CSS does the heavy lifting, so you do not need special classes on every child unless your design requires them.

A basic scroll-snap setup could look like this in CSS, assuming a full‑width, single‑slide view:

.carousel { display: flex; overflow-x: auto; scroll-snap-type: x mandatory; scroll-behavior: smooth; }
.carousel-item { flex: 0 0 100%; scroll-snap-align: center; }

Here the container uses display: flex to line its children up horizontally, and overflow-x: auto to enable scrolling. The magic is in scroll-snap-type: x mandatory, which tells the browser that horizontal scroll should always settle on a valid snap point, and in scroll-snap-align: center on each slide, which defines those snap points.

This combination of flexbox and scroll snap instantly feels like a carousel: users scroll or swipe, and the nearest slide snaps into place. Because it leverages native scrolling, touch input on phones and tablets just works, without any gesture handlers or extra libraries.

You are not restricted to one item per view. For multi‑column carousels that show several cards at once, you can use a flexible width for items, for example:

.carousel-item { flex: 0 0 calc(33.333% - 1rem); margin: 0 0.5rem; scroll-snap-align: center; }

This variation lets you display, say, three cards per viewport with a bit of spacing, while still benefiting from snapping behaviour. Users slide through pages of cards rather than single hero slides, which works beautifully for product lists or galleries.

Scroll buttons with the ::scroll-button() pseudo-element

Once you have a snapping scroller, the next thing most people want is explicit navigation buttons—previous and next controls that nudge the carousel left or right. Traditionally, you would create <button> elements, bind click events and compute how far to scroll.

The CSS Overflow 5 spec introduces ::scroll-button(), a pseudo-element that lets the browser do all that heavy lifting. While this is currently available starting in Chrome 135 (and typically behind experimental flags at first), it is a glimpse of how carousels will be authored going forward.

You declare scroll buttons the same way you would any other pseudo-element. For example, to generate left and right buttons for your carousel:

.carousel::scroll-button(left) { content: "⬅" / "Scroll left"; }
.carousel::scroll-button(right) { content: "⮕" / "Scroll right"; }
.carousel::scroll-button(*) :focus-visible { outline-offset: 5px; }

When this CSS is applied, the browser actually creates real <button> elements as siblings of the scroll container, hooks them up to the scroller, assigns appropriate roles, and wires their behaviour. A single click typically scrolls around 85% of the scroll area, which feels like a “page” in most carousel layouts.

If your design expects exactly one full‑width item per click, you can combine these pseudo-elements with stricter snapping on each child by adding scroll-snap-stop: always. That forces the viewport to land on item boundaries rather than stopping midway.

The content property here serves double duty: the first value is what users visually see (in this case, arrow characters), and the second string is accessible fallback text that can be exposed to assistive technology. You are free to style and position these buttons with CSS, or use functions like anchor() to align them precisely with your layout.

Scroll markers with ::scroll-marker() and ::scroll-marker-group

Dots or labels beneath a carousel—often called “pagination bullets” or “markers”—are another common requirement. They show how many items exist and which one is currently visible, and they let users jump directly to a specific slide.

The ::scroll-marker() pseudo-element gives you these indicators without manually generating anchor links or spans. Each marker is essentially a navigation <a> element created by the browser, which points to the corresponding scroll item and participates in keyboard and screen reader navigation.

To set this up, you tell the browser where to place the marker group with scroll-marker-group, and on which elements to create markers. A minimal configuration could look like this:

.carousel { scroll-marker-group: after; }
.carousel > li::scroll-marker { content: " "; }
.carousel > li::scroll-marker:target-current { background: var(--accent); }

The browser builds a ::scroll-marker-group container as a sibling of the scroller and populates it with one marker for each selected child. In this simple example the markers are rendered as empty dots, but you can just as easily use text like “Season 1”, icons or even thumbnails by changing the content property.

What makes these markers special compared to a plain scrollbar is that they are real navigational links. They let users jump straight to meaningful positions, and they come with useful semantics: there is a :target-current state indicating which marker corresponds to the slide currently in view or snapped. Keyboard support behaves like a focus group, and screen readers can expose them as a tablist‑like control.

This strategy is especially powerful when your scroller represents logical chunks rather than individual items. For example, instead of creating ten markers for ten episodes, you might create two markers that jump to the start of season one and season two. You are free to decide what “points of interest” should be addressable.

Combining buttons and markers into a full carousel UI

When you put ::scroll-button() and ::scroll-marker() together on the same scroller, you end up with a UI that users instantly recognise as a carousel: previous/next controls, a row of markers showing how far along they are, and smooth snapping between slides.

The key difference from classic JavaScript sliders is that most of this logic now lives in the browser. Buttons and markers are generated elements with correct ARIA roles, properly integrated into the tab order, and an accessibility story designed by the underlying browser teams rather than reinvented for each site.

This approach brings several very practical benefits: it works with JavaScript disabled, eliminates hydration flicker and timing issues, reduces Cumulative Layout Shift because controls are known at CSS layout time, and seamlessly integrates with scroll-driven animations or scroll‑state queries should you want to add flair.

On touch devices, the experience is naturally finger-friendly because the scroll container is still just a native scroller. On desktops, mousewheel and keyboard input work as expected, and focus indicators on generated controls can be styled to meet your design and accessibility guidelines.

From a maintenance perspective, “do less, reach more” becomes a pretty accurate description. Instead of maintaining the logic of multiple carousel implementations across projects, you lean on shared, standardised platform features that will keep improving over time with no extra work on your side.

Making Scroll Snap work in real projects

Even if you ignore the new pseudo-elements for a moment, scroll-snap alone is already good enough for many production carousels. The browser support for scroll-snap across modern engines is solid, and the behaviour is predictable both with mousewheel and touch gestures.

In the simplest scenario, your HTML carousel might be a div with a series of images inside. You can give that container a class like .carousel and leave the child images unclassified; CSS handles alignment and scrolling.

By turning on scroll-behavior: smooth on the container, scrolling between snap points becomes pleasantly animated instead of jumpy. This is particularly noticeable when users click markers or anchor links, or when you trigger scroll programmatically in hybrid setups.

Each key scroll-snap property plays a very distinct role. scroll-snap-type defines the axis and whether snapping is mandatory or just a hint; scroll-snap-align on children specifies where they lock into view (start, center, end); and scroll-snap-stop: always prevents the browser from skipping over snap points when scrolling quickly.

Used together, these properties let you dial in exactly how the carousel should feel—from gentle, forgiving snapping suitable for large content sections, to strict, slide‑by‑slide behaviour perfect for hero banners where you never want to land “between” items.

Because this is pure CSS, you can easily adapt the layout responsively. With media queries, swap from one slide per view on narrow screens to several per view on wide desktops, just by changing the flex-basis of items. No JavaScript breakpoints, resize listeners or recalculated widths required.

Experimental pseudo-elements and feature detection

The new carousel‑oriented pseudo-elements—::scroll-button(), ::scroll-marker() and ::scroll-marker-group—are powerful but still relatively fresh. At the time of writing they are available in Chrome 135 and later, often with experimental flags required, and they will roll out to other engines over time.

Because support is not yet universal, production sites should rely on feature detection and graceful fallbacks. CSS gives you the @supports at-rule, which lets you conditionally apply rules based on property recognition.

You might define a simple fallback navigation strip—a set of links or buttons that sit beneath the carousel—and then hide that strip when the browser supports native scroll buttons. For instance:

.carousel-nav { display: flex; gap: 0.5rem; }
@supports (scroll-button-inline: both) { .carousel-nav { display: none; } }

Here .carousel-nav can contain hand-authored anchor links pointing at slides via IDs, like <a href="#slide1">1</a>. On browsers that understand the new scroll-button property, your native controls appear and the fallback nav is hidden; on older browsers, the anchor navigation remains, paired with scroll-snap for a perfectly usable experience.

This hybrid strategy makes it safe to experiment with next‑generation carousel features today, without locking yourself into a particular engine or flag configuration. As browser support matures, you can lean more heavily on the pseudo-elements and trim some of the manual scaffolding.

CSS-only autoplay carousels with keyframe animations

Another common request is autoplay—a carousel that advances on its own, optionally pausing when hovered. While autoplay carries some UX and accessibility caveats, it is possible to achieve it purely with CSS animations.

A straightforward pattern is to animate the horizontal translation of your slide track.

@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); }
}
.autoplay-carousel { display: flex; animation: slide 12s infinite; }
.autoplay-carousel:hover { animation-play-state: paused; }

In this example, the keyframes spend chunks of time at each offset before moving to the next slide. The track loops back at the end, giving the appearance of a repeating slideshow over four slides. Hovering over the carousel pauses the animation, so users can inspect content without fighting constant motion.

To respect users who prefer reduced motion for health or comfort reasons, you can wrap the animation in a media query:

@media (prefers-reduced-motion: reduce) { .autoplay-carousel { animation: none; } }

This ensures that on systems where reduced motion is requested, slides remain static, effectively disabling autoplay while still allowing manual scroll or button/marker navigation where provided.

One important trade‑off is that keyframe-based autoplay does not integrate with scroll-snap automatically. You are manually translating the content rather than relying on native scroll, so you have to design the animation carefully to avoid half‑visible slides and to keep timing in sync with your layout.

Accessibility considerations for CSS carousels

Accessibility is an area where CSS-based carousels can genuinely shine, because a lot of the heavy logic shifts from your custom code to platform behaviours that have been carefully designed and tested.

When you rely on native scrolling plus scroll-snap, you automatically inherit keyboard, touch and mouse support. Users can tab into the scroller, use arrow keys to move, swipe on touchscreens and scroll with a wheel, without any event listeners or custom handlers.

The experimental scroll buttons and markers go a step further. The browser creates them with appropriate semantics, integrates them into the tab order, and maintains their state, making it very hard to accidentally forget ARIA attributes or mislabel them. The resulting carousel controls tend to be more consistently accessible than home‑rolled implementations.

You can and should still add semantic hints to your carousel regions. For instance, marking the container with role="region" and a descriptive aria-label helps screen reader users understand what the component represents:

<div class="carousel" role="region" aria-label="Product gallery">
<div class="carousel-item" aria-label="Slide 1 of 3">...</div>
</div>

Ensuring visible focus indicators for all interactive elements remains crucial. Whether the controls are your own buttons and links or browser-generated pseudo-elements, make sure focus outlines are not removed or made too subtle to see. Adjust outline, outline-offset or background changes as needed to meet contrast requirements.

Touch target size is another practical consideration. Aim for controls that are at least 44×44px to comply with common accessibility guidelines, so that users do not struggle to tap arrows or dots on smaller screens.

For any autoplay functionality, give users clear control and avoid overly aggressive motion. Provide a pause-on-hover or an explicit pause button, and consider defaulting autoplay off if the content is dense or text‑heavy. Combined with prefers-reduced-motion, this makes your carousel more comfortable for a wider audience.

Handling circular or “infinite” scrolling with CSS

One behaviour that people often ask for is circular scrolling: when you reach the last slide and scroll further, you smoothly wrap back to the first, like a physical carousel spinning endlessly.

Today, there is no direct, purely CSS property that makes a scroll container inherently circular. Without duplicating content, a native scroller will have a start and an end: once you reach the final item, further scrolling simply does not move you forward.

Spec authors are aware that many UI carousels expect this looping behaviour, and there is active interest in exploring platform-level solutions for cyclical scrolling, much the way new scroll buttons and markers were standardised. That said, at the moment you cannot ask a browser to wrap scroll positions natively with a single CSS declaration.

In purely CSS setups, simulating looping usually means animating transforms (as in autoplay keyframe examples) or accepting a linear end to the sequence. If seamless infinity is absolutely required and you cannot duplicate content, JavaScript is still the more flexible tool for now.

The good news is that nothing stops you from combining a mostly CSS‑driven carousel with a tiny bit of JavaScript where it genuinely adds value. You might rely on scroll-snap and CSS buttons for everyday navigation, then add a few lines of script to watch scroll position and jump from end to start when appropriate.

HTML/CSS versus JavaScript for rich, card-based carousels

A frequent dilemma for newer developers is whether a more complex card carousel “must” be written in JavaScript. People often imagine that as soon as you step beyond simple images into responsive cards with various components, CSS alone stops being viable.

In practice, CSS scroll-snap works just as well for rich cards as it does for plain images. As long as each card is a block within the scroll container, you can snap to it, animate around it and layer additional styles on top. Titles, buttons, fragments of text and even embedded media do not fundamentally change the mechanics.

Where JavaScript shines is in conditional behaviour and complex state. If your carousel must dynamically filter cards, reorder slides, sync with other components, fetch new content on the fly or support highly customised logic (“if this dot is clicked, show that totally unrelated card”), then JavaScript can express that business logic more easily than CSS alone.

However, a surprising number of everyday sliders are essentially static lists of items that advance linearly. For those cases, using a few lines of CSS rather than importing a large JS library is both simpler to reason about and better for performance. Understanding scroll-snap and the newer pseudo-elements lets you reserve JavaScript for the rare cases where it really pays for itself.

If you are still learning front‑end development, treating CSS carousels as your default baseline is a great habit. Start with HTML and CSS, and only reach for script when you hit a clear limitation. This teaches you more about what the platform can already do and often leads to leaner, cleaner codebases.

Stepping back, the emerging set of CSS features around scrolling, snapping, buttons, markers and scroll‑state queries has transformed carousels from script‑heavy widgets into mostly declarative components. By leaning on scroll-snap for core paging behaviour, using ::scroll-button() and ::scroll-marker() where supported, and applying feature detection and accessibility best practices, you can ship carousels that are fast, robust and easier to maintain than traditional JS-driven sliders—while still keeping enough flexibility to mix in JavaScript where cyclical logic or advanced interactions truly demand it.

propiedad css touch-action
Artículo relacionado:
Propiedad CSS touch-action: cómo domar gestos táctiles con precisión
Related posts: