Native CSS Controls for Carousels with the New Pseudo-elements

Build a modern carousel using native CSS controls with the new ::scroll-button() and ::scroll-marker pseudo-elements, with scroll snap and a lightweight JavaScript as a fallback for unsupported browsers.

Carousels have a habit of turning into far more JavaScript than they look like they should need. Previous and next buttons, pagination dots, tracking the current slide, disabled states, keyboard navigation, scroll math, resize handling, accessibility. All of it has to stay in sync, and it’s easy to end up maintaining a small state machine just to show a few cards in a row.

That’s finally starting to change. The CSS Overflow Module Level 5 adds a handful of carousel-related primitives: ::scroll-button(), ::scroll-marker, ::scroll-marker-group, :target-current. These let the browser generate navigation buttons and position markers for a scroll container on its own.

Browser support isn’t there across the board yet, so building something that leans on these features entirely still isn’t realistic. What I wanted to try instead was to see how little code it would actually take to build a carousel that behaves as a normal horizontal scroll everywhere, picks up native CSS controls where they exist, falls back to a small bit of JavaScript where they don’t, still works with JavaScript switched off, keeps its HTML semantic and accessible, handles RTL layouts, and respects reduced-motion preferences.

Call it less a “CSS-only” and more a progressively enhanced carousel, because the browser does more of the work wherever it already knows how.

Start with a scrollable list

Skip the carousel API for a second and start with the part that doesn’t need one. The most important part of this component is also the least exciting: a horizontally scrollable list.

<div class="carousel" role="region" aria-labelledby="carousel-title">
  <h2 class="visually-hidden" id="carousel-title">Example items</h2>

  <ul class="carousel-track">
    <li class="carousel-item">Item 1</li>
    <li class="carousel-item">Item 2</li>
    <li class="carousel-item">Item 3</li>
    <li class="carousel-item">Item 4</li>
    <li class="carousel-item">Item 5</li>
  </ul>
</div>

I’ve used a <ul> here because the content really is a list of items. It also gives assistive technologies something sensible to work with before any carousel behavior gets layered on (current guidance for carousel-like content recommends keeping list semantics when the content is naturally a list).

Nothing fancy in the CSS either:

.carousel-track {
  ...
  display: flex;
  flex-wrap: nowrap;
  ...
  overscroll-behavior-inline: contain;
  scroll-behavior: smooth;
  scroll-padding-inline: var(--padding);
  scroll-snap-type: inline mandatory;
  ...
}

.carousel-item {
  ...
  scroll-snap-align: start;
  scroll-snap-stop: always;
}

At this point, it already works. Mouse and trackpad users can scroll it, touch users can swipe it, keyboard users can tab into the scroll container, and the visible scrollbar hints that there’s more content off to the side.

That’s really the essential point of progressive enhancement here: the carousel shouldn’t stop working just because the decorative controls failed to load. Scroll snapping makes the experience nicer without changing that underlying model.

Use native CSS controls

This is where it gets interesting. The scroll container can generate its own previous and next buttons straight from CSS, using ::scroll-button():

.carousel-track::scroll-button(inline-start) {
  content: "‹" / "Previous item";
}

.carousel-track::scroll-button(inline-end) {
  content: "›" / "Next item";
}

I’ve also used logical properties like inline instead of hard-coding left and right. That pays off later, once RTL layouts come into the picture.

And these aren’t decorative pseudo-elements dressed up as buttons. The browser gives you real, focusable, interactive controls and manages their state itself. Once the scroll container can’t move any further in a direction, the matching button picks up :disabled automatically.

Because the spec defines both the activation behavior and the disabled state, you can style them pretty much like ordinary buttons:

.carousel-track::scroll-button(inline-start),
.carousel-track::scroll-button(inline-end) {
  aspect-ratio: 1;
  ...
  display: inline-grid;
  ...
  width: 2.5rem;
}

.carousel-track::scroll-button(inline-start):disabled,
.carousel-track::scroll-button(inline-end):disabled {
  cursor: default;
  opacity: 0.25;
}

One small detail worth calling out is what’s happening in the content property:

content: "‹" / "Previous item";

Everything after the / is replacement text for the generated content, and it gives the control an accessible name instead of leaving assistive tech to guess what an arrow character is supposed to mean.

Here’s the detail that trips people up when comparing native scroll buttons to a JavaScript carousel: it’s tempting to assume the native “next” button means “go to the next carousel item.” That’s not actually how ::scroll-button() works.

The native button just performs a page-style scroll in its direction, and scroll snapping decides where that movement actually lands. If only one large item is visible at a time, you’ll barely notice the difference. But with several smaller cards on screen, a single click can jump past more than one item.

Using:

.carousel-track {
  scroll-snap-type: inline mandatory;
}

.carousel-item {
  scroll-snap-stop: always;
}

makes the behavior more predictable when individual items should act as stopping points.

There’s a progressive-enhancement lesson buried in here too: a fallback doesn’t need to replicate the native algorithm exactly. What matters is that it feels equivalent and predictable to use.

For this carousel, I wanted the fallback controls to move exactly one item at a time, so the JavaScript works out the real rendered distance between two neighboring items instead of trying to mimic the browser’s page-scrolling behavior.

Buttons handle previous/next, but most carousels also need dots showing the current position, and the new CSS can generate those too.

First, tell the scrolling container where the marker group should appear:

.carousel-track {
  scroll-marker-group: after;
}

Then generate one marker for every item:

.carousel-item::scroll-marker {
  aspect-ratio: 1;
  ...
  width: 0.75rem;
}

The currently relevant marker can be selected with :target-current:

.carousel-item::scroll-marker:target-current {
  background: deeppink;
}

That removes another piece of state you would normally track in JavaScript. The browser already knows which item is current, so it knows which marker to light up too, and in this way the generated marker content can carry a meaningful accessible name as well.

Scroll markers aren’t just painted dots, the spec defines real focus and navigation behavior for the marker group, including keyboard interaction and the right roles depending on the marker mode.

Recent Chromium updates also let generated marker content contribute meaningful accessible names, counters included. That lets us take the example a little further:

.carousel-track {
  counter-reset: carousel-item;
}

.carousel-item {
  counter-increment: carousel-item;
}

.carousel-item::scroll-marker {
  content: "" / "Go to item " counter(carousel-item);
}

Visually it’s still just a dot, but the accessible text behind it is doing a lot more work now.

Progressive enhancement

So what happens in a browser that doesn’t support ::scroll-button()?

I didn’t want to duplicate the whole carousel implementation, so the HTML just includes two ordinary previous/next buttons and an empty marker group, which JavaScript only populates when native markers aren’t available:

<div
  class="carousel-markers-fallback"
  role="group"
  aria-label="Carousel navigation"
></div>

<div class="carousel-controls-fallback">
  <button
    type="button"
    class="carousel-prev"
    aria-label="Previous item"
    aria-controls="carousel-track"
  >
    <span aria-hidden="true">‹</span>
  </button>

  <button
    type="button"
    class="carousel-next"
    aria-label="Next item"
    aria-controls="carousel-track"
  >
    <span aria-hidden="true">›</span>
  </button>
</div>

The important part here is that scroll buttons and scroll markers are two separated enhancements, not one package deal.

A browser could ship support for one before the other, so the fallback can’t assume that ::scroll-button() support implies ::scroll-marker support.

The CSS can hide each fallback independently:

@supports selector(::scroll-button(inline-start)) {
  .carousel-controls-fallback {
    display: none;
  }
}

@supports selector(::scroll-marker) {
  .carousel-markers-fallback {
    display: none;
  }
}

The same distinction applied in JavaScript:

const supportsNativeScrollButtons = Boolean(
  window.CSS
    && window.CSS.supports
    && CSS.supports('selector(::scroll-button(inline-start))')
);

const supportsNativeScrollMarkers = Boolean(
  window.CSS
    && window.CSS.supports
    && CSS.supports('selector(::scroll-marker)')
);

That leaves four valid combinations instead of one all-or-nothing feature check: native buttons with native markers, native buttons with fallback markers, fallback buttons with native markers, or fallback buttons with fallback markers.

That’s a much cleaner enhancement boundary than a single yes/no check, and it’s exactly what the demo uses: @supports switching from the HTML buttons over to the generated controls.

Small JavaScript fallback

The fallback isn’t a second carousel implementation. CSS still owns the scroll container, its layout, and the snap positions. The small JavaScript just adds equivalent previous/next navigation and recreates the small bit of control state the browser would otherwise hand us for free.

For this design, one click should move exactly one item, and the distance between two items isn’t just the card width, the gap between flex items counts too.

So the fallback measures both:

function getScrollAmount() {
  const firstItem = items[0];
  const itemWidth = firstItem.getBoundingClientRect().width;
  const computedStyle = getComputedStyle(track);
  const columnGap = parseFloat(computedStyle.columnGap);
  const gapValue = Number.isNaN(columnGap)
    ? (parseFloat(computedStyle.gap) || 0)
    : columnGap;

  return itemWidth + gapValue;
}

That gapValue line is easy to overlook.

Say a card is 300px wide and the gap is 32px. Scroll only 300px and the next movement lands 32px short of where the following item actually starts.

Scroll snapping might paper over part of that gap, but leaning on it means the requested scroll position and the actual snap position stop matching up. Depending on the layout and the browser, that shows up as partially visible cards, or navigation that just doesn’t quite start where you’d expect.

Instead, the requested movement matches the layout: rendered item width + rendered gap.

Using getBoundingClientRect() also means the JavaScript reads the real rendered width instead of hard-coding a value that duplicates the CSS.

So if the carousel gets narrower or wider at some breakpoint, the very next click just uses the new distance, and the calculated movement lines up with the next item’s position, and CSS Scroll Snap still handles the final alignment.

This gives us predictable one-item-at-a-time navigation without duplicating layout values in JavaScript.

Marker fallback

The marker fallback is solving a different problem. A previous/next control means “move one item this direction.” A marker means “take me to this specific item.”

That means there’s no need to reuse the width-and-gap math, because the fallback can just target the item directly with scrollIntoView():

marker.addEventListener('click', () => {
  item.scrollIntoView({
    block: 'nearest',
    inline: 'start',
  });
});

inline: 'start' matches the scroll-snap-align: start model and remains logical-direction aware.

I’ve deliberately left behavior: 'smooth' out of this call. Smooth scrolling already lives in CSS, so one single reduced-motion rule governs native interaction and both fallback interactions.

The marker buttons themselves can be generated from the items:

markers = items.map((item, index) => {
  const marker = document.createElement('button');

  marker.type = 'button';
  marker.className = 'carousel-marker-fallback';
  marker.setAttribute('aria-label', `Go to item ${index + 1}`);

  marker.addEventListener('click', () => {
    item.scrollIntoView({
      block: 'nearest',
      inline: 'start',
    });
  });

  markerGroup.append(marker);

  return marker;
});

This keeps the HTML minimal while still producing actual button elements when the fallback is needed.

The most important part of the marker fallback is what’s missing from it: there’s no activeSlide variable anywhere.

Swipe the carousel, scroll it with a trackpad, click previous/next, click a marker, whatever. The moment you introduce a separate slide-index variable, it becomes one more piece of state you have to keep in sync.

Instead, the fallback derives the current marker from the real layout.

Because the items use scroll-snap-align: start, we can find the item closest to the logical snap-start edge:

function updateMarkerState() {
  const trackRect = track.getBoundingClientRect();
  const computedStyle = getComputedStyle(track);
  const isRtl = computedStyle.direction === 'rtl';
  const paddingInlineStart = parseFloat(computedStyle.paddingInlineStart) || 0;
  const snapStart = isRtl
    ? trackRect.right - paddingInlineStart
    : trackRect.left + paddingInlineStart;

  let currentIndex = 0;
  let smallestDistance = Infinity;

  items.forEach((item, index) => {
    const itemRect = item.getBoundingClientRect();
    const itemStart = isRtl ? itemRect.right : itemRect.left;
    const distance = Math.abs(itemStart - snapStart);

    if (distance < smallestDistance) {
      smallestDistance = distance;
      currentIndex = index;
    }
  });

  markers.forEach((marker, index) => {
    if (index === currentIndex) {
      marker.setAttribute('aria-current', 'true');
    } else {
      marker.removeAttribute('aria-current');
    }
  });
}

So, the marker state is really just a projection of the scroll position, not a state of its own, which is why swiping the carousel updates the dots exactly as reliably as clicking them does.

Reuse one scroll update for both fallbacks

The previous/next fallback already needs to update its disabled state while the carousel scrolls. There’s no reason to create a second scroll listener for markers.

Both can be grouped into one state update:

function updateState() {
  updateButtonState();
  updateMarkerState();
}

And one animation-frame-throttled listener:

let updateFrame = null;

track.addEventListener('scroll', () => {
  if (updateFrame !== null) {
    return;
  }

  updateFrame = requestAnimationFrame(() => {
    updateState();
    updateFrame = null;
  });
}, { passive: true });

It’s still deliberately small, because all this JavaScript does is to watch the state your browser already owns, rather than building a separate carousel engine on the side.

Equivalent behavior for main interactions

Equivalent behavior isn’t the same as identical implementation, and that distinction is worth spelling out.

The native and fallback versions do not execute the same algorithms.

Native:

::scroll-button()
  ↓
browser performs page-oriented scrolling
  ↓
scroll snapping determines the stopping point

Fallback:

HTML button
  ↓
JavaScript calculates item width + gap
  ↓
scrollBy()
  ↓
scroll snapping provides final alignment

Native:

::scroll-marker
  ↓
browser targets the associated snap area
  ↓
:target-current exposes the current marker

Fallback:

HTML button
  ↓
scrollIntoView() targets the associated item
  ↓
scroll position determines aria-current

Internally, they are different.

On the other hand, from the user’s perspective, both paths deliver on the same things that matter: previous and next move through the carousel, markers jump straight to a specific item, the current marker tracks the real scroll position, and everything still snaps to the same CSS points.

That’s the kind of equivalence progressive enhancement should aim for.

Trying to replicate every internal detail of the new browser APIs in JavaScript would defeat the point. The fallback needs to cover what users actually need, not turn into a polyfill for the whole CSS feature.

One tip: don’t set smooth scrolling in both CSS and JavaScript at the same time.

It’s easy to end up with this:

track.scrollBy({
  left: amount,
  behavior: 'smooth',
});

while also having:

.carousel-track {
  scroll-behavior: smooth;
}

Only one of the two should own that decision.

I prefer CSS, because it gives us one place to respect the user’s motion preferences:

@media (prefers-reduced-motion: reduce) {
  .carousel-track {
    scroll-behavior: auto;
  }
}

The fallback JavaScript simply calls scrollBy() and scrollIntoView() without specifying behavior.

That way, native interaction and both of the JavaScript-assisted ones all respect the same motion preference.

Recreate only the needed native state

One thing the browser handles automatically for ::scroll-button() is disabling the control when there’s nowhere left to scroll.

The fallback HTML buttons need to recreate that behavior.

Instead of tying this too heavily to scrollLeft, which gets awkward once RTL layouts are involved, the fallback can compare the geometry of the first and last items against the visible scroll container.

The marker fallback follows the same principle. The browser’s :target-current state is replaced by a single aria-current="true" attribute on whichever fallback marker corresponds to the item nearest the snap-start edge.

Either way, we’re only recreating the bare minimum of state needed for an equivalent interaction, but nothing more.

Logical directions make RTL support easier. The native CSS uses:

::scroll-button(inline-start)
::scroll-button(inline-end)

rather than:

::scroll-button(left)
::scroll-button(right)

That distinction matters.

inline-start and inline-end follow the document’s writing direction. In an LTR document, inline start is on the left. In an RTL document, it’s on the right.

The previous/next fallback therefore also checks:

const isRtl = getComputedStyle(track).direction === 'rtl';

before converting “start” and “end” into a physical scrolling direction.

The marker fallback can remain logical by using:

item.scrollIntoView({
  block: 'nearest',
  inline: 'start',
});

And when deriving the current marker, it compares item edges against the left side in LTR and the right side in RTL.

Using logical directions from the start makes all of this a lot easier than retrofitting RTL behavior after the fact.

Enhanced positioning for CSS controls

Another feature I experimented with in the demo is CSS Anchor Positioning.

The scroll container becomes a named anchor:

.carousel-track {
  anchor-name: --carousel;
}

and the buttons can then be placed around it with position-anchor and position-area.

But there’s an important architectural detail here: anchor positioning should not be required for the fallback to work.

The fallback controls therefore have a simple normal-flow layout first. Anchor positioning is another optional enhancement layered on top.

This creates several independent layers:

  • horizontal scrolling
  • scroll snapping
  • HTML previous/next fallback controls
  • JavaScript previous/next fallback behavior
  • HTML marker fallback container
  • JavaScript marker fallback behavior
  • anchor positioning
  • native CSS scroll buttons
  • native CSS scroll markers

Pull out any one layer and everything underneath still works. That’s what progressive enhancement should actually feel like.

Make it accessible

One of the most appealing parts of these new primitives is how much state the browser can manage itself. Native scroll buttons are focusable and expose their disabled state. Scroll markers provide browser-managed navigation and current-state behavior.

But that doesn’t mean adding ::scroll-button() and ::scroll-marker makes any carousel automatically accessible. The surrounding HTML still matters.

In the demo, I retain the list semantics of the items, give the carousel an accessible name, and use actual <button> elements for fallback controls.

The fallback markers also receive useful names:

marker.setAttribute('aria-label', `Go to item ${index + 1}`);

and the current one receives:

aria-current="true"

Focus states remain visible for both native and fallback controls.

The exact accessibility strategy should still depend on what type of carousel is being built. A single-slide hero, a product gallery, and a horizontally scrollable list of related articles don’t necessarily have identical requirements.

Browser-provided behavior cuts down what you have to get right by hand, but it doesn’t remove the need to actually test the finished thing with a keyboard and assistive technology.

For a simple carousel, these platform features tip the cost-benefit calculation quite a bit.

The baseline component already gets:

  • touch and trackpad scrolling from the browser
  • scroll snapping from CSS
  • native previous and next controls when supported
  • native disabled states
  • native marker navigation when supported
  • native current-marker state when supported
  • an equivalent HTML/JavaScript fallback everywhere else
  • logical writing directions
  • reduced-motion support

There’s very little application state to synchronize, because the scroll position itself remains the source of truth.

The fallback isn’t trying to replicate the native implementations, it’s only filling in whatever interaction is missing.

That doesn’t make JavaScript carousel libraries pointless. Plenty of use cases still need a lot more than this: infinite looping, autoplay, virtualization, heavily dynamic slide management, synchronized galleries, or app-specific event APIs.

But for a large category of “show me a horizontal list with previous/next buttons and pagination dots” components, pulling in an entire carousel library is getting much harder to justify.

The part I like the most

The most interesting thing about these new pseudo-elements isn’t that we can technically create a carousel “with CSS.” We’ve been able to build scroll-snapping CSS carousels for a long time.

The interesting part is that the platform is beginning to understand what the controls around a scroll container actually mean.

Rather than hand-building every control, calculating every destination, keeping an active slide index in sync, and managing disabled states ourselves, we can increasingly describe the relationship in CSS and let the browser take it from there.

And progressive enhancement means we don’t have to choose between adopting a new API and supporting browsers that don’t have it yet.

It doesn’t need to be a pixel-perfect reimplementation of the native feature, it can be just an equivalent, usable one. In this example that means:

  • native browsers get browser-managed ::scroll-button() controls
  • fallback buttons get explicit one-item-at-a-time navigation based on the real card width and gap
  • native browsers get ::scroll-marker navigation and :target-current
  • fallback markers use ordinary buttons, scrollIntoView(), and aria-current
  • every version shares the same underlying scroll container and the same CSS snap points
  • the scroll position stays the single source of truth

The implementations differ, but the component’s purpose stays the same. Start with a scroll container that works on its own. Layer in the native features wherever the browser understands them, recreate only what’s essential where it doesn’t, and keep that fallback as small as you can get away with.

That’s a far more useful definition of a “modern CSS carousel” to me than chasing zero JavaScript for its own sake.

The Live Demo

The carousel works as a regular scrollable, scroll-snapping list first.
Native CSS scroll buttons and markers progressively enhance it when supported.

The cards are intentionally slightly narrower than the visible track. This leaves a small part of the next card in view as a visual cue that there is more content to scroll to. The spacing is deliberate rather than an alignment error: each card is one gap narrower than the track, and the fallback navigation advances by the rendered card width plus that gap so every item still lands on its snap position.

Note: The article featured image was generated using Adobe Firefly AI technology.

If you would like to support my work, consider making a donation, buy me a coffee, or share this on your feed.
A huge thanks in advance!

Iulia Cazan
Privacy Overview

This website uses cookies so that we can provide you with the best user experience possible. Cookie information is stored in your browser and performs functions such as recognising you when you return to our website and helping our team to understand which sections of the website you find most interesting and useful.