The WAI-ARIA Carousel pattern done right: aria-roledescription, visible pause button (WCAG 2.2.2), inerthidden slides so keyboard can’t Tab into them, and a reduced-motion default that never auto-starts — Vanilla and React.
A pattern most sites get wrong. Here's the APG-correct version.
What we are building
A carousel is a rotating set of slides. It’s the hardest common component to get right — autoplay triggers WCAG 2.2.2, hidden slides can create keyboard traps, SR announcements need careful timing, and reduced-motion users need an opt-out.
This chapter follows the WAI-ARIA Authoring Practices Carousel pattern. The strong recommendation: default to manual (no autoplay). If you must autoplay, pause on hover + focus + via a visible button, and never auto-start when the user has reduced motion.
2
The 4 real-world variants
Switch between the inaccessible version and the accessible fix.
The 4 real-world variants
Variant A
Manual carousel
Preferred default
Live example
Fastest audits in the industry
Scan 10,000 pages in under 2 minutes.
WCAG 2.2 + 2.1 coverage
Every success criterion mapped.
Export VPAT, PDF, CSV
Share results with your legal team.
1 / 3
Manual carousel — no autoplay, no WCAG 2.2.2 problem. Prev/Next announce the new slide.
Respect prefers-reduced-motion — never auto-start when set.
2.1.1 Keyboard (A)
Pause, Prev, Next, dots must all be keyboard-operable.
2.1.2 No Keyboard Trap (A)
Tab must not reach hidden-slide content — use inert, not just hidden.
4.1.2 Name, Role, Value (A)
region + aria-roledescription + per-slide group exposes the structure.
7
Testing approach
Framework-agnostic patterns. Pick the one that matches your stack.
Testing approach
Pattern: region has aria-roledescription → click Next → live region announces new slide → hover pauses → Tab can’t reach hidden-slide content.
Jest + Testing Library + jest-axe
AccessibleCarousel.test.jsx
// Jest + @testing-library/react + jest-axe
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { axe, toHaveNoViolations } from "jest-axe";
import AccessibleCarousel from "./AccessibleCarousel";
expect.extend(toHaveNoViolations);
const slides = [
{ id: "a", title: "Alpha", body: "A" },
{ id: "b", title: "Beta", body: "B" },
];
test("exposes region with aria-roledescription=carousel", () => {
render(<AccessibleCarousel slides={slides} label="Promos" />);
const region = screen.getByRole("region", { name: "Promos" });
expect(region).toHaveAttribute("aria-roledescription", "carousel");
});
test("Next button advances slides and announces to live region", async () => {
const user = userEvent.setup();
render(<AccessibleCarousel slides={slides} label="Promos" />);
await user.click(screen.getByRole("button", { name: /next slide/i }));
const status = await screen.findByRole("status");
expect(status).toHaveTextContent(/slide 2 of 2/i);
});
test("has no axe violations", async () => {
const { container } = render(
<AccessibleCarousel slides={slides} label="Promos" />
);
expect(await axe(container)).toHaveNoViolations();
});
Vitest + Testing Library
AccessibleCarousel.test.jsx
// Vitest + @testing-library/react
import { expect, test } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import AccessibleCarousel from "./AccessibleCarousel";
test("hidden slides are inert so Tab does not reach them", async () => {
render(
<AccessibleCarousel
slides={[
{ id: "a", title: "A", body: "A", cta: { href: "/a", label: "Visit A" } },
{ id: "b", title: "B", body: "B", cta: { href: "/b", label: "Visit B" } },
]}
label="X"
/>
);
const user = userEvent.setup();
await user.tab();
// Should land on prev or next button, NOT on the hidden slide's "Visit B" link
expect(screen.queryByRole("link", { name: "Visit B", hidden: true })).toBeInTheDocument();
// ...but it must not be reached via Tab because its ancestor is inert
});
Playwright + @axe-core/playwright
carousel.spec.js
// Playwright + @axe-core/playwright
import { test, expect } from "@playwright/test";
import AxeBuilder from "@axe-core/playwright";
test("carousel page has no serious a11y violations", async ({ page }) => {
await page.goto("/en/accessible-carousel");
const results = await new AxeBuilder({ page })
.withTags(["wcag2a", "wcag2aa", "wcag22aa"])
.analyze();
const serious = results.violations.filter(
(v) => v.impact === "critical" || v.impact === "serious"
);
expect(serious).toEqual([]);
});
test("autoplay pauses on hover (WCAG 2.2.2)", async ({ page }) => {
await page.goto("/en/accessible-carousel");
const promo = page.getByRole("region", { name: /promotions/i });
const counter = promo.locator("text=/\\d \\/ \\d/");
const before = await counter.textContent();
await promo.hover();
await page.waitForTimeout(3500);
const after = await counter.textContent();
expect(after).toBe(before); // did not advance while hovered
});
8
Manual QA checklist
Tick each item before shipping.
Manual QA checklist
Region has role="region" + aria-roledescription="carousel" + aria-label
Each slide has role="group" + aria-roledescription="slide" + aria-label="N of M"
Hidden slides use BOTH hidden AND inert
If autoplay: a visible pause/play button (aria-pressed, aria-label changes with state)
Autoplay pauses on hover
Autoplay pauses on keyboard focus-in
Never auto-start when prefers-reduced-motion: reduce
Dots (if used) grouped with role="group" and each labelled with the slide it goes to
User-initiated slide changes announced via polite live region
Zero critical / serious axe violations
9
Common mistakes
The carousel bugs we see on nearly every audit.
Common mistakes
Autoplay with no pause. Direct WCAG 2.2.2 failure.
Hidden slides only use display:none. Still focusable in some setups — use inert.
Dots with no labels. SR hears “button, button, button”.