All WordPress HTML Templates Forms & Webhooks AI & Tools
HTML Templates

Accessible Carousels, or the Case for Not Using One

An accessible carousel is solvable, but usually not worth it. Real click data, the WCAG 2.2 failures we find in audits, and a CSS scroll snap build that

A real working moment illustrating the theme of an article about accessible carousel. Wide 16:9 banner, one strong focal point, magazine editorial quality, authentic and unstaged.

Erik Runyon’s click data from Notre Dame is the number everyone quotes, and it still holds up: roughly one percent of visitors ever clicked a carousel item, and the overwhelming majority of those clicks landed on the first slide. The same shape appears in client analytics. A five-slide hero on a B2B site where slide one takes 89 percent of the clicks, slides four and five take fewer than ten clicks a month between them, and the whole component costs 180KB of JavaScript.

So the honest version of this article starts here: an accessible carousel is a solvable engineering problem. Solving it is usually a waste of a sprint, because the component rarely earns its keep. But “delete it” is not always an option when the client’s marketing director has four stakeholder logos to rotate. So we’ll do both: the case against, then the build that actually passes an audit.

Key Takeaways

  • Carousels concentrate almost all engagement on the first slide, so content on slides two and beyond is effectively unpublished. Measure before you build: if slide 3 gets under 1 percent of clicks, you have a stack, not a carousel.
  • Use inert on off-screen slides, never aria-hidden alone. aria-hidden="true" on a slide containing a link hides it from screen readers while leaving it in the tab order, which is one of the most common failures we find in audits.
  • A CSS scroll snap carousel with scroll-snap-type: x mandatory gives you touch, trackpad, keyboard and native scrollbar support in about 15 lines of CSS, with JavaScript only for the arrow buttons.
  • Autoplay triggers WCAG 2.2.2 (Pause, Stop, Hide). If it moves for more than five seconds, you need a visible pause control, and it should be the first focusable thing in the component.
  • Chromium shipped CSS-only carousels (::scroll-button(), ::scroll-marker, scroll-marker-group) in Chrome 135. They are genuinely good, with correct focus and ARIA semantics built in, but they are still Chromium-only, so treat them as progressive enhancement.

What the click data actually says

Three separate client datasets from the last two years, all e-commerce or lead-gen, all with hero carousels we inherited. The pattern is identical in every one: slide one takes between 80 and 92 percent of all carousel clicks, and total carousel engagement sits between 0.8 and 3 percent of sessions. On mobile it is worse. The slide transition competes with the user’s scroll intent, and people swipe past the whole block in under a second.

There is a second cost that does not show up in a click report. A hero carousel is almost always the Largest Contentful Paint element, and rotating images means you either preload all of them (bad) or preload one and let the rest cost network on idle (acceptable, but now you have a layout and decode cost on every rotation). We’ve measured LCP improvements of 400 to 900ms on mid-tier Android simply by replacing a four-slide hero with a single static hero and a row of links. If you are rebuilding a hero anyway, the question of what that fold should actually contain is the more profitable conversation.

The counterargument worth taking seriously: carousels are fine when the user has already expressed intent in the content type. Product image galleries, related products, a photo set on a property listing. Those are browse-by-choice components. The user arrived wanting more of the same thing. That is a different interaction from a homepage hero shouting four unrelated messages at someone who has not decided anything yet.

A real working moment that fits an article section about

The failure modes we keep finding in audits

Almost every broken carousel we’ve audited fails in one of these five ways. None of them are exotic.

  • aria-hidden over focusable content. The library hides inactive slides with aria-hidden="true" but leaves the links tabbable. A keyboard user tabs into a void: focus moves to something a screen reader refuses to announce, and the viewport may not even scroll to it. This is a direct axe-core violation (aria-hidden-focus) and it is the single most common finding.
  • Drag-only controls. WCAG 2.2 added 2.5.7 Dragging Movements. If the only way to advance is a swipe or mouse drag, you fail. You need buttons or a native scrollbar.
  • Dot indicators under 24 by 24 CSS pixels. Success Criterion 2.5.8 Target Size (Minimum) is AA in WCAG 2.2. An 8px dot with no padding fails. Keep the visual dot small if you must, but pad the hit area to 24px.
  • Autoplay with no pause. 2.2.2 is a Level A criterion. No pause control means you fail at the lowest conformance level, which matters for anything procurement-facing or public sector.
  • Focus loss on transition. User tabs to “Next”, the slide changes, the library re-renders the track, and focus falls back to document.body. Keyboard users are returned to the top of the page. Test this one by tabbing and pressing Enter five times in a row.

The ARIA layer, briefly

Slider accessibility ARIA is simpler than most libraries make it. The APG basic carousel pattern is a role="group" with aria-roledescription="carousel" and a real aria-label. Each slide is a group with aria-roledescription="slide" and a label like “2 of 5”. That is it. Do not use role="region" on every slide, do not nest live regions, and do not add role="tablist" to your dots unless you have actually built the tabbed variant, where each dot is a tab and each slide is a tabpanel. Half-implemented tab semantics are worse than none: the screen reader promises arrow-key navigation that your code never delivers. The broader point about using the right element for the job is covered in our piece on semantic HTML.

Build the accessible carousel with CSS scroll snap

If you ship one, ship a scroll container. A CSS scroll snap carousel gives you momentum scrolling, trackpad gestures, touch, a focusable scroll area, and in Chromium a keyboard-focusable scroller by default since Chrome 127. The browser does the hard part.

<div class="carousel" role="group" aria-roledescription="carousel" aria-label="Featured projects">
  <ul class="carousel__track" tabindex="0" aria-label="Featured projects, scrollable list">
    <li class="carousel__slide">
      <article><h3><a href="/work/atlas">Atlas Rebrand</a></h3></article>
    </li>
    <li class="carousel__slide"><!-- ... --></li>
  </ul>
  <div class="carousel__controls">
    <button type="button" data-dir="-1" aria-label="Scroll to previous projects"></button>
    <button type="button" data-dir="1" aria-label="Scroll to next projects"></button>
  </div>
</div>

Note the tabindex="0" on the track. Safari and Firefox do not yet make overflow containers keyboard focusable automatically, so you add it manually. The aria-label on a focusable scroll region is what stops VoiceOver announcing a nameless “group”.

.carousel__track {
  display: flex;
  gap: 1rem;
  overflow-x: auto;
  scroll-snap-type: x mandatory;
  scroll-behavior: smooth;
  / stops the page bouncing when the user hits the end of the track /
  overscroll-behavior-inline: contain;
  / logical property: flips automatically in RTL /
  scroll-padding-inline-start: 1rem;
  list-style: none;
  margin: 0;
  padding-block: 0.5rem;
}

.carousel__slide {
  flex: 0 0 min(80%, 22rem);
  scroll-snap-align: start;
}

.carousel__track:focus-visible {
  outline: 3px solid Highlight;
  outline-offset: 2px;
}

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

Because the track is a flex row using logical scroll padding, it flips correctly when the document direction is RTL. Physical scroll-padding-left does not, and that bug is nearly invisible until an Arabic build lands. The full set of these gotchas is covered in RTL support and CSS logical properties.

The JavaScript is twelve lines and only enhances what already works:

const carousel = document.querySelector('.carousel');
const track = carousel.querySelector('.carousel__track');

carousel.querySelectorAll('[data-dir]').forEach((btn) => {
  btn.addEventListener('click', () => {
    const dir = Number(btn.dataset.dir);
    // scroll by one viewport of the track, not one slide, so partial
    // slides never leave a sliver stranded at the edge
    track.scrollBy({ left: dir  track.clientWidth  0.9, behavior: 'smooth' });
  });
});

// disable arrows at the ends instead of wrapping: wrapping breaks
// the user's mental model of where they are in a finite list
const sync = () => {
  const max = track.scrollWidth - track.clientWidth;
  carousel.querySelector('[data-dir="-1"]').disabled = track.scrollLeft <= 1;
  carousel.querySelector('[data-dir="1"]').disabled = track.scrollLeft >= max - 1;
};
track.addEventListener('scroll', sync, { passive: true });
sync();

If the JavaScript fails to load, the component is a horizontally scrollable list that still works with touch, trackpad and the scrollbar. That is the whole argument for progressive enhancement in one component. Carousel libraries that transform the DOM on init give you a broken block instead.

One real caveat: scroll-snap-type: x mandatory can trap users on slides taller than the viewport, and it interferes with “find in page” scrolling in some engines. If your slides are text-heavy or variable height, use x proximity instead and accept slightly looser snapping.

CSS-only carousels: where the platform is now

Chrome 135 shipped the CSS carousel primitives in 2025. They are the first time the platform has given us arrows and dots with correct semantics for free. The browser generates real buttons, puts them in the right tab order, and marks the active dot with :target-current.

.carousel__track {
  scroll-marker-group: after; / generates the dot strip after the scroller /
}

/ the string after the slash becomes the accessible name /
.carousel__track::scroll-button(inline-start) {
  content: "\2039" / "Previous projects";
}
.carousel__track::scroll-button(inline-end) {
  content: "\203A" / "Next projects";
}
.carousel__track::scroll-button(*):disabled { opacity: .35; }

.carousel__slide::scroll-marker {
  content: "";
  inline-size: 24px; block-size: 24px; / meets target size minimum /
  border: 2px solid currentColor; border-radius: 50%;
}
.carousel__slide::scroll-marker:target-current { background: currentColor; }

The inline-start and inline-end keywords mean the arrows flip in RTL without a line of extra code. Dots are keyboard navigable with arrow keys because the browser implements the focus group. The limitation, as of early 2026: this is Chromium only. Safari and Firefox have not shipped it. Layer it on top of the scroll snap base and gate it with @supports selector(::scroll-marker) so you don’t render two sets of dots.

Patterns that beat a carousel

Before you build any of the above, check whether one of these does the job with less code and more clicks.

  • A static hero plus a link row. Four messages, four visible links. We’ve swapped this in on three client sites and engagement with the secondary messages went up every time, because they stopped being invisible 70 percent of the time.
  • An overflowing scroller with a visible edge. Deliberately let the next card peek 20 percent into view. That peek tells users to scroll. No dots, no arrows, no autoplay. This is what most product listing rows should be.
  • A grid that becomes a scroller only at narrow widths. Container queries make this clean: grid when the component has room, scroll snap when it does not. The mechanics are covered in rebuilding components with container queries.
  • Tabs. If the content is genuinely parallel categories, tabs are a better pattern with a well-defined, well-supported ARIA contract. Users understand them. They do not move on their own.

If you want to see how this plays out across a lot of layouts at once, the carousel components in Canvas default to scroll-snap behaviour with real buttons rather than drag-only interactions, precisely because the drag-only version keeps failing audits.

How to actually test it

Automated tools catch maybe 40 percent of what’s wrong with a carousel. axe DevTools will flag aria-hidden-focus and missing button names. It will not notice that focus vanished after a slide change, or that your autoplay restarts after the user pauses it. Four manual checks, five minutes total:

  1. Tab through the whole component with the mouse unplugged. Focus should never land on something invisible, and the scroller should bring focused content into view.
  2. Turn on VoiceOver or NVDA and listen for the slide count. “Slide 2 of 5” should be announced on change, and nothing should be announced while autoplay is running unless the user stopped it.
  3. Set prefers-reduced-motion: reduce in DevTools rendering panel. Autoplay should stop entirely, not just slow down.
  4. Zoom to 400 percent at 1280px wide. Per SC 1.4.10, content must reflow without horizontal page scroll. A carousel with absolutely positioned slides usually breaks here.

Frequently Asked Questions

Is a carousel automatically a WCAG failure?

No. A carousel with visible previous and next buttons, keyboard support, no autoplay and correct ARIA can pass AA cleanly. The failures come from implementation details: autoplay without a pause control fails 2.2.2 at Level A, drag-only controls fail 2.5.7, and tiny dots fail 2.5.8. The pattern is legal, the typical build is not.

Should off-screen slides use aria-hidden or inert?

Use inert. It removes the subtree from the accessibility tree and the tab order in one attribute, which is exactly what you want, and it has been supported in all current browsers since 2023. aria-hidden="true" alone leaves links and buttons tabbable, producing focus that screen readers refuse to announce. If you need legacy support, pair aria-hidden with tabindex="-1" on every focusable descendant, and keep them in sync on every transition.

Does a scroll snap carousel work on desktop without a touchscreen?

Yes, with two additions. Trackpad users get horizontal scrolling natively, but mouse-wheel-only users do not, so you need the arrow buttons. Give the track tabindex="0" so keyboard users can focus the scroll area and use the arrow keys, since Safari and Firefox do not make overflow containers focusable automatically yet.

Can I keep autoplay if the client insists?

You can, if you add a pause button as the first focusable control inside the component, stop rotation permanently on any focus or hover inside the carousel, and honour prefers-reduced-motion: reduce by not starting at all. Also set aria-live="off" while rotating and switch it to polite only once the user has taken manual control. Even then, our advice on client projects is to show the analytics first: autoplay rarely moves the numbers it is supposed to move.

Are the new CSS carousel pseudo-elements production ready?

They are production ready as an enhancement, not as a baseline. ::scroll-button() and ::scroll-marker shipped in Chrome 135 and give you correct button semantics and keyboard handling for free, but Safari and Firefox have not shipped them as of early 2026. Build the scroll snap version first, then wrap the pseudo-element styles in @supports selector(::scroll-marker) and hide your JavaScript controls inside that same block.

Make the call before you build

Pull the click data for your existing carousel. If slides three and beyond are under one percent, you do not have an accessibility problem, you have a content problem. The fix is to delete three slides and promote the one that works. That conversation takes an hour and saves a week.

If the component survives that test, build it as a scroll container with real buttons, inert on what’s off-screen, no autoplay, and CSS scroll markers layered on for Chromium. You will spend less time on it than configuring most carousel libraries, and it will still work when the JavaScript bundle times out on a train.

accessible carousel accessible carousel WCAG 2.2 carousel autoplay pause stop hide css scroll snap carousel example inert vs aria-hidden carousel slides keyboard accessible slider without javascript scroll-marker-group css carousel Chrome slider accessibility aria roledescription