How to Build Accessible Carousels
Build a carousel people can discover, operate, and understand with a keyboard or screen reader. Includes semantic HTML, optional rotation, CSS, JavaScript, and a practical test checklist.
A usable accessible carousel has semantic structure, native previous and next buttons, a clear indication of the current slide, and changes that assistive technology can communicate without moving focus unexpectedly. If it rotates automatically, give users a visible stop/start control, stop rotation when keyboard focus enters or a pointer hovers, and do not restart just because focus leaves. Consider whether a static list would serve the content better; carousels can make content harder to discover.
This guide builds a small manual carousel first, then explains how to add optional rotation. The HTML, CSS, and JavaScript are complete examples you can adapt. Treat them as a starting point: check the result in the browsers and assistive technologies your audience uses.
1. Choose a suitable interaction model
Use a carousel when showing one item at a time helps people understand or navigate a related set of content. If visitors need to compare all items or discover them quickly, a regular list or grid may be clearer. Consider the content inside slides too: a simple image carousel is easier to manage than slides containing multiple interactive controls.
| Choice | Good starting point | Consider |
|---|---|---|
| Manual or automatic movement | Manual movement avoids changes the visitor did not request. | Automatic movement needs a visible pause/stop control, focus and hover behavior, and a decision about reduced motion. |
| Previous/next or direct pickers | Previous and next buttons are a simple baseline. | Tabs or grouped slide buttons enable direct selection but add keyboard and focus decisions. |
| Controls over images or beside them | Controls on a solid background are easier to keep legible. | Overlays need sufficient contrast against every image or a solid backing. |
Choose one coherent interaction model and test it. Do not add decorative dots as the only way to identify or select a slide.
2. Add semantic structure and names
Use a visible heading to name the carousel, then associate the container with that heading. A region is useful when the carousel merits a landmark in the page; a group may be more appropriate when it is a smaller part of a larger section. Give each slide a useful accessible name. A position such as “Slide 2 of 5” can distinguish slides when meaningful names are unavailable.
The example uses a list because the slides form a collection. It marks inactive slides hidden so their content is not exposed as if it were visible. If your slides contain interactive content, verify that hidden slides cannot receive focus and choose a hiding strategy that works with your layout and framework.
3. Implement a manual carousel
Save this as an HTML file and open it in a browser. Replace the sample content and image sources with your own. The previous and next buttons are native buttons, the live region announces user-requested changes, and focus stays on the button after activation.
<section class="carousel" aria-labelledby="stories-heading">
<h2 id="stories-heading">Customer stories</h2>
<div class="carousel__controls">
<button type="button" data-prev aria-label="Previous slide">Previous</button>
<button type="button" data-next aria-label="Next slide">Next</button>
</div>
<ul class="carousel__slides">
<li class="carousel__slide" role="group" aria-roledescription="slide" aria-label="1 of 3">
<img src="images/story-one.jpg" alt="A baker arranging loaves on a counter">
<h3>A neighborhood bakery</h3>
<p>How a small team organized its online orders.</p>
</li>
<li class="carousel__slide" role="group" aria-roledescription="slide" aria-label="2 of 3" hidden>
<img src="images/story-two.jpg" alt="A gardener tending rows of seedlings">
<h3>A community garden</h3>
<p>Sharing growing advice with local volunteers.</p>
</li>
<li class="carousel__slide" role="group" aria-roledescription="slide" aria-label="3 of 3" hidden>
<img src="images/story-three.jpg" alt="A person packing a parcel at a desk">
<h3>An independent shop</h3>
<p>Keeping customers informed about new arrivals.</p>
</li>
</ul>
<p class="visually-hidden" data-status aria-live="polite" aria-atomic="true"></p>
</section>
<style>
.carousel { max-width: 48rem; margin-inline: auto; }
.carousel__controls { display: flex; gap: .75rem; margin-block: 1rem; }
.carousel button { min-width: 2.75rem; min-height: 2.75rem; padding: .5rem .9rem; }
.carousel button:focus-visible { outline: 3px solid #1457d9; outline-offset: 3px; }
.carousel__slides { list-style: none; padding: 0; margin: 0; }
.carousel__slide img { display: block; width: 100%; height: auto; }
.visually-hidden {
position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0;
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { scroll-behavior: auto !important; }
}
</style>
<script>
const carousel = document.querySelector('.carousel');
const slides = [...carousel.querySelectorAll('.carousel__slide')];
const status = carousel.querySelector('[data-status]');
let current = 0;
function showSlide(index, announce = true) {
current = (index + slides.length) % slides.length;
slides.forEach((slide, i) => {
const active = i === current;
slide.hidden = !active;
slide.setAttribute('aria-hidden', String(!active));
});
if (announce) {
status.textContent = `Slide ${current + 1} of ${slides.length}: ` +
slides[current].querySelector('h3')?.textContent;
}
}
carousel.querySelector('[data-prev]').addEventListener('click', () => {
showSlide(current - 1);
});
carousel.querySelector('[data-next]').addEventListener('click', () => {
showSlide(current + 1);
});
showSlide(0, false);
</script>
The slide labels here provide a position. If each slide has a concise, unique name, use that as its accessible name instead, or include both name and position. Avoid repeating “carousel” in every name when the containing section already identifies it. The code assumes one carousel on the page; scope selectors to a component instance if there are multiple.
The live region is polite and only updated for button-initiated changes. Keep the user’s focus on the activated control so they can press it repeatedly. The WAI tutorial describes a separate slide-picker interaction that may move focus to a selected item; do not combine that behavior indiscriminately with previous/next navigation.
4. Add optional slide pickers
Direct slide selection can help when there are few slides. One simple option is a group of buttons. Give each button a name that says which slide it selects, mark the current selection with aria-current, and keep the visual current state distinguishable by more than color alone.
<div class="carousel__pickers" role="group" aria-label="Choose a customer story">
<button type="button" data-slide="0" aria-label="Show slide 1: A neighborhood bakery" aria-current="true">1</button>
<button type="button" data-slide="1" aria-label="Show slide 2: A community garden">2</button>
<button type="button" data-slide="2" aria-label="Show slide 3: An independent shop">3</button>
</div>
<script>
const pickers = [...carousel.querySelectorAll('[data-slide]')];
pickers.forEach((button) => button.addEventListener('click', () => {
showSlide(Number(button.dataset.slide));
pickers.forEach((picker, i) => {
if (i === current) picker.setAttribute('aria-current', 'true');
else picker.removeAttribute('aria-current');
});
}));
</script>
This grouped-button approach leaves each button in the normal tab order, which can become tedious with many slides. The APG also describes a tabs approach; if using tabs, implement the tabs keyboard pattern rather than merely applying tab roles to ordinary buttons. For a large set, rethink whether a carousel is the right presentation.
5. Add automatic rotation only when it helps
Manual movement is the simpler and often calmer default. If automatic rotation is necessary, provide a visible start/stop button before the other carousel controls in the tab sequence. Stop when keyboard focus enters and when a pointer hovers. Do not automatically resume on blur; require an explicit restart. Avoid announcing every timed change to screen readers.
WCAG 2.2 Success Criterion 2.2.2 is Level A. It requires a mechanism to pause, stop, or hide certain automatically started moving, blinking, or scrolling information lasting more than five seconds when presented alongside other content, subject to the criterion’s details and exceptions. Assess the actual behavior against the full criterion; the pattern below is not a conformance determination.
<button type="button" data-rotation aria-label="Start slide rotation">Play</button>
<script>
const rotationButton = carousel.querySelector('[data-rotation]');
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
let timer = null;
let rotating = false;
function stopRotation() {
rotating = false;
window.clearInterval(timer);
timer = null;
rotationButton.textContent = 'Play';
rotationButton.setAttribute('aria-label', 'Start slide rotation');
status.setAttribute('aria-live', 'polite');
}
function startRotation() {
if (reduceMotion.matches) return;
rotating = true;
rotationButton.textContent = 'Pause';
rotationButton.setAttribute('aria-label', 'Stop slide rotation');
status.setAttribute('aria-live', 'off');
timer = window.setInterval(() => showSlide(current + 1, false), 5000);
}
rotationButton.addEventListener('click', () => {
if (rotating) stopRotation(); else startRotation();
});
carousel.addEventListener('focusin', stopRotation);
carousel.addEventListener('mouseenter', stopRotation);
reduceMotion.addEventListener?.('change', () => {
if (reduceMotion.matches) stopRotation();
});
if (reduceMotion.matches) stopRotation();
</script>
Combine this with the manual example’s script rather than including two independent initialization blocks that declare the same variables. In a production component, initialize rotation as paused, avoid restarting if a user has explicitly stopped it, and handle browsers that need the older addListener media-query API if they are in your support target. The sample stops when focus enters, including focus on the rotation button; a user can then activate it to start. Rotation timing and pause behavior should not make slide content too brief to read.
6. Style for contrast, focus, and small screens
- Use readable text sizes and avoid clipping captions or controls at narrow widths.
- Keep controls visible; swipe or drag must not be the only navigation method.
- Use a strong visible focus indicator for keyboard users.
- Check text and control contrast, especially over images. A solid control background can make contrast more dependable.
- Show the selected picker with a shape, border, weight, or other cue in addition to color.
- WAI’s styling tutorial recommends at least 44 × 44 CSS pixels for buttons and links that are not inline in text. This is the tutorial’s recommendation associated with WCAG’s AAA target-size-enhanced criterion, not a WCAG 2.2 AA minimum.
Do not hide controls until hover: touch, keyboard, and many other users may not have a hover interaction. Preserve a non-swipe way to operate every function.
7. Test behavior with people’s input methods
Automated checks can catch some markup issues, but they cannot establish that the interaction makes sense across assistive technologies. Test the rendered component in representative browser and assistive-technology combinations for your users.
- Keyboard: Tab to the carousel and through its controls. Activate previous and next with Enter and Space. Confirm focus remains predictable and every operation is available without dragging.
- Rotation: If enabled, confirm focus entering and pointer hover stop it, leaving focus does not restart it, and the user can explicitly restart it. Check reduced-motion preference behavior.
- Screen reader: Check the carousel’s name, slide name or position, button names, and announcement after manual changes. Confirm timed rotation does not repeatedly interrupt reading.
- Visibility: Confirm hidden slides are not exposed or focusable, and the visible slide remains available. Check images have useful alternatives when informative and empty alternatives when decorative.
- Visual and responsive: Check contrast, focus visibility, current picker indication, caption readability, and controls at narrow viewport sizes.
- Touch: Operate all controls without relying on a swipe gesture.
W3C cautions that its APG examples are illustrative and support varies between browser and assistive-technology combinations, especially on mobile and touch devices. Use the APG as pattern guidance and WCAG as the source for conformance requirements; ARIA attributes do not replace semantic HTML or working behavior.
8. Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Screen reader does not announce the new slide | The live region is missing, not updated, or changed in a way the tested combination does not announce. | Update a persistent polite status node after user-triggered navigation; test actual output with target screen readers. |
| Screen reader announces every autoplay change | The live region announces timer-driven movement. | Set announcements off while rotating and stop rotation on focus. Consider removing autoplay. |
| Keyboard focus disappears after Next | Code moves focus into content that is replaced or hidden. | Keep focus on the activated navigation button for previous/next actions. |
| Users can tab into an invisible slide | The slide is visually hidden with CSS only, or interactive descendants remain focusable. | Use a robust hidden state such as the HTML hidden attribute and verify the framework does not override it. |
| Controls are unreadable on some images | Overlay contrast changes with the underlying image. | Put controls on a solid background or provide a sufficiently opaque backing and verify contrast. |
| Rotation restarts unexpectedly | A blur handler restarts it when focus leaves. | Do not resume automatically after focus leaves; require an explicit start action. |
| Only swipe works on a phone | Buttons were hidden at small widths or omitted. | Keep visible previous/next controls and test without gestures. |
| Wrong slide appears after rapid activation | State changes are asynchronous or event handlers use a stale index. | Keep the selected index in one component state source and update visibility, status, and picker state together. |
9. Performance, reliability, and implementation costs
A carousel should not load more work than its content needs. Large images increase transfer and rendering costs; size them appropriately and consider lazy loading off-screen slide images. If an image is needed immediately for the visible slide, avoid delaying that slide’s content. Lazy loading slide images can also mean a newly selected slide briefly has no image, so check that transition on slower connections.
Keep one source of truth for the current index and update slide visibility, accessible status, and picker state together. Guard against empty slide sets before using modulo arithmetic, and make sure controls have a defined disabled or hidden behavior when there is only one slide. If slide content is fetched dynamically, provide a loading and failure state and do not announce an item as selected before it is available. These details improve reliability without requiring a carousel library.
The simplest component has no timer, no external dependency, and only a few state transitions. Autoplay, tabs, dynamically loaded slides, and interactive slide content each add behavior that needs implementation and testing. Choose the least complex version that serves the content.
10. Or skip the browser setup
If the carousel is part of a page you need to inspect, you can capture the page with ScreenshotNeo, a website screenshot API and MCP server for developers. A single GET request returns an image or PDF; see the API documentation.
curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
import requests
r = requests.get("https://api.screenshotneo.com/v1/shot", params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"}, timeout=90)
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
Cookie banners, popups, and chat widgets are removed before the shot. Bot checks, blank pages, and failed loads are never billed. An MCP server lets AI agents use screenshot tools. The free plan includes 1,000 screenshots a month with no card; paid plans start at $5 for 3,000. Sign up for 1,000 free screenshots a month, no card required.
11. Frequently asked questions
Does adding aria-roledescription make a carousel accessible?
No. It can clarify the role description, but meaningful names, semantic content, keyboard behavior, and understandable changes matter too.
Should every carousel autoplay?
No. Manual navigation is often simpler. Use automatic rotation only when it serves the content and visitors can control it.
Are carousel dots enough for navigation?
Not by themselves. Provide clearly named controls and ensure the current slide is identifiable without relying on color.
Does passing an automated accessibility scan prove conformance?
No. Combine automated checks with keyboard, visual, and assistive-technology testing against the relevant WCAG criteria.


