How to Build a JavaScript Image Carousel
Build an accessible image carousel with semantic HTML, responsive CSS, and JavaScript navigation. Add keyboard controls, screen reader announcements, and reduced-motion support.
A JavaScript image carousel is a collection of slides, native previous and next buttons, and a small amount of JavaScript that tracks which slide is visible. The example below is a complete, responsive carousel with keyboard-accessible controls, a screen-reader announcement, and reduced-motion support. It wraps from the last slide to the first; disabling a direction at each end is also a valid design choice.
1. Build the HTML structure
Give the carousel a meaningful accessible name. Use a list for the slides and native buttons for navigation. Each image needs alternative text that describes its content and purpose. If a nearby caption already conveys the same information, avoid repeating it in the image’s alt text.
<section class="carousel" aria-roledescription="carousel" aria-labelledby="gallery-title">
<h2 id="gallery-title">Featured landscapes</h2>
<div class="carousel__viewport">
<ul class="carousel__slides">
<li class="carousel__slide" aria-roledescription="slide" aria-label="1 of 3">
<img src="images/coast.jpg" alt="Rocky coast beneath a cloudy sky">
<p>The northern coast</p>
</li>
<li class="carousel__slide" aria-roledescription="slide" aria-label="2 of 3" hidden>
<img src="images/forest.jpg" alt="Sunlight falling across a forest path">
<p>A forest walk</p>
</li>
<li class="carousel__slide" aria-roledescription="slide" aria-label="3 of 3" hidden>
<img src="images/mountains.jpg" alt="Snow-covered mountain peaks at sunrise">
<p>Mountain sunrise</p>
</li>
</ul>
</div>
<div class="carousel__controls">
<button type="button" data-carousel-prev aria-label="Previous slide">Previous</button>
<p data-carousel-status aria-live="polite" aria-atomic="true">Item 1 of 3</p>
<button type="button" data-carousel-next aria-label="Next slide">Next</button>
</div>
</section>
The hidden attribute keeps inactive slides out of the visual layout and accessibility tree. The live status announces changes without moving focus from the button a person activated.
2. Add responsive styling
This CSS keeps the slide within its container, preserves image proportions, and fades between slides. The transition is optional; reduced-motion preferences disable it below.
.carousel {
max-width: 48rem;
margin-inline: auto;
}
.carousel__viewport {
overflow: hidden;
}
.carousel__slides {
margin: 0;
padding: 0;
list-style: none;
}
.carousel__slide img {
display: block;
width: 100%;
height: auto;
aspect-ratio: 16 / 9;
object-fit: cover;
}
.carousel__slide[hidden] {
display: none;
}
.carousel__slide:not([hidden]) {
animation: carousel-enter 180ms ease-out;
}
.carousel__controls {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-block-start: 0.75rem;
}
.carousel__controls button {
min-height: 2.75rem;
padding: 0.5rem 0.9rem;
}
.carousel__controls button:focus-visible {
outline: 3px solid currentColor;
outline-offset: 3px;
}
@keyframes carousel-enter {
from { opacity: 0.5; }
to { opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
.carousel__slide:not([hidden]) {
animation: none;
}
}
For icon-only controls, retain an accessible name such as aria-label="Next slide". Keep the focus indicator visible. The prefers-reduced-motion media feature lets the interface remove non-essential animation when a visitor has requested less motion. See MDN’s reduced-motion guidance.
3. Add JavaScript navigation
Save this as carousel.js and load it with <script src="carousel.js" defer></script>. The script supports multiple carousels on a page, wraps at either end, updates slide position labels, and leaves focus on the activated control.
document.querySelectorAll('.carousel').forEach((carousel) => {
const slides = Array.from(carousel.querySelectorAll('.carousel__slide'));
const previous = carousel.querySelector('[data-carousel-prev]');
const next = carousel.querySelector('[data-carousel-next]');
const status = carousel.querySelector('[data-carousel-status]');
if (slides.length === 0 || !previous || !next || !status) return;
let current = slides.findIndex((slide) => !slide.hidden);
if (current < 0) current = 0;
function showSlide(index) {
current = (index + slides.length) % slides.length;
slides.forEach((slide, slideIndex) => {
slide.hidden = slideIndex !== current;
slide.setAttribute('aria-label', `${slideIndex + 1} of ${slides.length}`);
});
status.textContent = `Item ${current + 1} of ${slides.length}`;
}
previous.addEventListener('click', () => showSlide(current - 1));
next.addEventListener('click', () => showSlide(current + 1));
showSlide(current);
});
For production markup, ensure every slide has a useful name. The example’s positional aria-label identifies its place; you can make each label more descriptive, for example “1 of 3: The northern coast.” The W3C carousel pattern describes naming the carousel and slides, native button controls, and keeping focus in place during navigation: WAI-ARIA APG Carousel Pattern.
4. Decide how navigation should behave
| Choice | Behavior | Use when |
|---|---|---|
| Wrap | Next on the last slide returns to the first, and previous on the first returns to the last. | The carousel is a continuous gallery. This is what the code above does. |
| Stop at the ends | Disable Previous on the first slide and Next on the last. | Going back to the start after reaching the end would be surprising. Update each button’s disabled state in showSlide. |
| Slide pickers | Provide a button for each slide. | People need to jump directly to a known item. Give each picker a name and expose the current choice, for example with aria-current="true". |
| Multiple visible items | Show a group of items at once and advance by one item or one page. | The content is a product strip or card row. Define which item counts as current and announce navigation consistently. |
A row of picker buttons adds keyboard tab stops. A tabbed picker can reduce the number of stops, but it must also implement the keyboard behavior of tabs. For guidance on announcing current content and focus, see W3C WAI carousel functionality.
5. Keyboard, screen readers, and automatic rotation
- Use real
buttonelements so keyboard activation works natively. Keep visible focus styling. - Use a polite live region for manual changes, such as “Item 2 of 3.” Do not send focus to the slide after activation.
- Use a meaningful accessible name for the carousel and describe the slide content. Avoid redundant image alternative text when an adjacent caption already says the same thing.
- Automatic rotation is usually unnecessary for a gallery. If you add it, include a rotation control first in the carousel’s tab sequence. Stop rotation when keyboard focus enters or the pointer hovers, and do not restart after focus has entered until the visitor explicitly asks. Provide pause and resume controls.
- Honor reduced-motion preferences for non-essential transitions. Avoid making motion the only way to understand that content changed.
The W3C pattern covers rotation controls, stopping on focus, and user-directed restart. MDN explains prefers-reduced-motion and reducing non-essential animation: MDN.
6. Consider CSS scroll snapping
If the main need is a horizontally scrollable gallery that settles on item boundaries, CSS scroll snapping can reduce custom navigation code. MDN also documents newer CSS carousel features for generated scroll buttons and markers. Browser compatibility for those newer features was not verified for this guide, so check support against the browsers your audience uses before relying on them. CSS does not remove the need for semantic structure, accessible names, keyboard access, and a clear way to communicate the current item.
.scroll-gallery {
display: grid;
grid-auto-flow: column;
grid-auto-columns: min(85%, 32rem);
gap: 1rem;
overflow-x: auto;
padding: 0 0 1rem;
scroll-snap-type: x mandatory;
overscroll-behavior-inline: contain;
}
.scroll-gallery > * {
scroll-snap-align: start;
}
This is a scrollable gallery, not a drop-in replacement for every scripted carousel. Compare JavaScript and CSS based on target browser support, whether you need custom state or transitions, how many items appear at once, and how the current item is communicated. Read MDN’s CSS carousel guide and verify compatibility before using the newer generated controls.
7. Handle images and edge cases
- No slides: Avoid initializing controls for an empty carousel. The JavaScript above exits if it finds no slides.
- One slide: Navigation is redundant. Hide or disable the controls when there is only one item.
- Broken or slow images: Use valid image paths and dimensions or
aspect-ratioto reduce layout shifts. Consider a fallback background or error state if image availability is important. - Large image sets: Load only the current image and nearby slides eagerly; use lazy loading for off-screen images. Do not lazy-load the first visible image if it is important to initial rendering.
- JavaScript disabled or fails: With the example’s initial
hiddenattributes, only the first slide appears, so the content remains readable. If all content must be reachable without JavaScript, use a scrollable list or progressive enhancement that starts with all slides visible and applies carousel behavior after initialization. - Dynamic slides: If items are added or removed after initialization, update the slide collection and position count. A static array captured once will not include later changes.
- Right-to-left pages: Decide whether Previous and Next follow the content sequence or physical screen direction. Keep button names and behavior consistent with that choice.
8. Troubleshoot common problems
| Symptom | Likely cause | Fix |
|---|---|---|
| Buttons do nothing | The script ran before the markup existed, a selector does not match, or a JavaScript error stopped initialization. | Use defer on the script, verify the data attributes, and check the browser console. |
| Two slides appear at once | The inactive slide’s hidden state is not being set, or another CSS rule overrides it. |
Check slide.hidden = slideIndex !== current and preserve .carousel__slide[hidden] { display: none; }. |
| Images are stretched or the layout jumps | Image dimensions vary or are not reserved before load. | Set width: 100%, height: auto, and a consistent aspect-ratio; use object-fit: cover if cropping is acceptable. |
| Screen readers do not announce changes | The status element is missing a live-region attribute or its text is not updated. | Keep aria-live="polite" and update the status text on every navigation action. |
| Focus jumps or disappears | The script focuses the slide, replaces the button, or rebuilds the carousel after each change. | Update the existing slide visibility and status without moving focus or replacing controls. |
| Reduced-motion setting has no effect | The animation rule is more specific or another script adds motion. | Inspect computed styles and disable non-essential transitions as well as keyframe animation within the reduced-motion query. |
| CSS generated controls do not appear | The target browser may not support the newer CSS carousel features. | Check compatibility data for the target browsers and provide a scrollable or JavaScript fallback. |
9. Performance and reliability
A basic carousel needs no library: its navigation code is small, and changing the active slide does not require a network request. Image downloads and decoding are usually the larger costs. Serve appropriately sized images, reserve their dimensions to limit layout shifts, and avoid loading a large gallery’s full-resolution assets at once. Test touch scrolling and button navigation at narrow widths, and check that a failed image does not make the controls unusable.
For a carousel with many slides or dynamic content, centralize updates in one function so visibility, status text, button states, and any picker state stay in sync. Keep navigation deterministic: one activation should produce one visible state change and one corresponding announcement. Avoid automatic rotation unless there is a clear user benefit, since it adds pause behavior and timing concerns.
Or skip the browser setup
If your goal is to capture a page or carousel state as an image, ScreenshotNeo can return a screenshot from one API request. It is a website screenshot API and MCP server for developers, made by Yorker Media. The API can capture PNG, JPEG, WebP, or PDF; see the ScreenshotNeo 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}`);
if (!res.ok) throw new Error(`Screenshot request failed: ${res.status}`);
await Bun.write('shot.webp', res);
Before capture, ScreenshotNeo accepts cookie or consent banners like a visitor and removes more than 60 known consent platforms, newsletter popups, and chat widgets; each step can be turned off. Bot checks and CAPTCHAs, blank pages, timeouts, failed loads, and cache hits cost nothing, and response headers report the page verdict and billing status. Its MCP server gives AI agents tools to take screenshots, get page information, and capture PDFs. The Free plan includes 1,000 screenshots per month with no card; paid plans start at $5 for 3,000 screenshots. Sign up for 1,000 free screenshots a month, with no card required.
FAQ
Does an image carousel need JavaScript?
No. A scrollable gallery can use CSS overflow and scroll snapping. JavaScript is useful when you need explicit previous and next controls, custom state, or scripted transitions.
Should the carousel advance automatically?
Usually only when automatic movement serves a clear purpose. If it rotates, provide a stop and restart control, stop on focus and hover, and require an explicit restart after keyboard focus enters.
Should next and previous wrap around?
Either wrapping or stopping at the ends can work. Choose based on the content and make the behavior consistent; the sample wraps.
How can I test the carousel without a screen reader?
Navigate using only the keyboard, check focus visibility, use reduced-motion settings, resize the viewport, and verify that each button action changes the image and status. Also inspect the accessibility tree with your browser’s developer tools.


