Image Slider JavaScript: Build an Accessible Carousel
Three photographs sat inside a slider that worked until someone swiped, pressed Tab, resized the window, or disabled JavaScript. The pictures were present, but the component had four different ideas about which one was current.
An accessible JavaScript image slider uses a horizontally scrollable CSS Scroll Snap track, native button controls, layout-derived state, responsive images, and reduced-motion handling so clicking, swiping, resizing, and browsing without JavaScript all reach the same content.
What We Are Building
An image slider, also called a carousel, presents an ordered collection of images within one viewing area. It is different from a before-and-after comparison slider, and it has nothing to do with an <input type="range"> control.
The component has five named parts:
- The carousel is the complete labeled component.
- A slide contains one image and its optional caption.
- The track is the horizontal scrolling list that holds the slides.
- The controls move to the previous or next slide.
- The indicators show the active position and move directly to another slide.
CSS owns the basic interaction. The track scrolls horizontally, each slide fills the viewport, and Scroll Snap settles the viewport on one slide. A touch gesture, trackpad movement, or dragged scrollbar works before JavaScript runs.
JavaScript adds another route through the same track. It connects the buttons, wraps navigation at either end, and synchronizes the indicators after any kind of scrolling.
That division is progressive enhancement: the ordered images exist first, CSS adds a usable scrolling layout, and JavaScript adds controls without becoming the only way to reach the content. The finished initializer also keeps its state inside each carousel, so several sliders can run on one page without shared global variables.
If you want an automatically changing gallery instead, Image Slideshow JavaScript: Build It From Scratch covers that related pattern. This slider stays manual.
Write Semantic Slider HTML
Start with an ordered list because the sequence still means something when the carousel behavior disappears. Each list item contains a figure, which keeps the image and caption together.
The carousel receives a visible heading through aria-labelledby, plus role="region" and aria-roledescription="carousel". Each list item contains a named slide group with its position in the set.
The controls are real button elements. They already support keyboard focus and activation, so you do not need clickable spans, link-shaped buttons, inline onclick attributes, or custom Tab handling.
The complete markup uses three local image files at three widths:
<section
class="slider"
data-slider
role="region"
aria-roledescription="carousel"
aria-labelledby="coast-slider-title"
>
<h2 id="coast-slider-title">Coastal walking route</h2>
<ol
class="slider__track"
id="coast-slider-track"
data-slider-track
tabindex="0"
aria-label="Coastal walking route slides"
>
<li class="slider__slide" data-slide>
<div
role="group"
aria-roledescription="slide"
aria-label="Cliff path, slide 1 of 3"
>
<figure class="slider__figure">
<img
class="slider__image"
src="images/cliffs-960.jpg"
srcset="
images/cliffs-640.jpg 640w,
images/cliffs-960.jpg 960w,
images/cliffs-1600.jpg 1600w
"
sizes="(min-width: 50rem) 50rem, 100vw"
width="1600"
height="900"
loading="eager"
alt="A narrow path following grass-covered cliffs above the sea"
>
<figcaption>Cliff path, the first section of the route</figcaption>
</figure>
</div>
</li>
<li class="slider__slide" data-slide>
<div
role="group"
aria-roledescription="slide"
aria-label="Blue cove, slide 2 of 3"
>
<figure class="slider__figure">
<img
class="slider__image"
src="images/cove-960.jpg"
srcset="
images/cove-640.jpg 640w,
images/cove-960.jpg 960w,
images/cove-1600.jpg 1600w
"
sizes="(min-width: 50rem) 50rem, 100vw"
width="1600"
height="900"
loading="lazy"
alt="A sheltered blue cove between two rocky headlands"
>
<figcaption>Blue cove, halfway along the route</figcaption>
</figure>
</div>
</li>
<li class="slider__slide" data-slide>
<div
role="group"
aria-roledescription="slide"
aria-label="Lighthouse, slide 3 of 3"
>
<figure class="slider__figure">
<img
class="slider__image"
src="images/lighthouse-960.jpg"
srcset="
images/lighthouse-640.jpg 640w,
images/lighthouse-960.jpg 960w,
images/lighthouse-1600.jpg 1600w
"
sizes="(min-width: 50rem) 50rem, 100vw"
width="1600"
height="900"
loading="lazy"
alt="A white lighthouse beyond a field at sunset"
>
<figcaption>Lighthouse, the final stop</figcaption>
</figure>
</div>
</li>
</ol>
<div class="slider__controls" data-slider-controls hidden>
<button
class="slider__arrow"
type="button"
data-slider-previous
aria-controls="coast-slider-track"
aria-label="Show previous slide"
>
Previous
</button>
<div
class="slider__indicators"
data-slider-indicators
role="group"
aria-label="Choose a slide"
>
<button
class="slider__indicator"
type="button"
data-slider-indicator
aria-controls="coast-slider-track"
aria-label="Cliff path, slide 1 of 3"
aria-current="true"
aria-disabled="true"
>
1
</button>
<button
class="slider__indicator"
type="button"
data-slider-indicator
aria-controls="coast-slider-track"
aria-label="Blue cove, slide 2 of 3"
aria-current="false"
>
2
</button>
<button
class="slider__indicator"
type="button"
data-slider-indicator
aria-controls="coast-slider-track"
aria-label="Lighthouse, slide 3 of 3"
aria-current="false"
>
3
</button>
</div>
<button
class="slider__arrow"
type="button"
data-slider-next
aria-controls="coast-slider-track"
aria-label="Show next slide"
>
Next
</button>
</div>
<p
class="slider__status"
data-slider-status
aria-live="polite"
aria-atomic="true"
>
Slide 1 of 3: Cliff path, the first section of the route
</p>
</section>
The controls begin with hidden, so dead buttons never appear when JavaScript fails to load. The list remains visible and scrollable.
Each informative image has alt text that describes the photograph. The caption instead explains its place in the route, so the two strings do different jobs.
When you place another slider on the same page, give its heading and track different IDs, then point its aria-labelledby and aria-controls attributes at those IDs. The classes and data attributes stay unchanged.
Create the Responsive Scroll-Snap Track
The track is both the list and the scroll container. Flexbox puts its children in one row, overflow-x: auto preserves native horizontal scrolling, and each slide takes 100% of the track width.
scroll-snap-type: x mandatory opts the horizontal axis into snapping. Each slide then declares scroll-snap-align: start, so its leading edge becomes a snap position.
Add the complete slider stylesheet:
.slider {
width: min(100%, 50rem);
margin-inline: auto;
}
.slider__track {
display: flex;
gap: 0;
margin: 0;
padding: 0;
overflow-x: auto;
overscroll-behavior-inline: contain;
scroll-behavior: smooth;
scroll-snap-type: x mandatory;
scrollbar-width: thin;
list-style: none;
}
.slider__slide {
flex: 0 0 100%;
min-width: 0;
scroll-snap-align: start;
scroll-snap-stop: always;
}
.slider__figure {
margin: 0;
}
.slider__image {
display: block;
width: 100%;
height: auto;
aspect-ratio: 16 / 9;
object-fit: cover;
border-radius: 0.5rem;
}
.slider__figure figcaption {
padding-block: 0.75rem;
}
.slider__controls {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 0.75rem;
margin-top: 0.75rem;
}
.slider__controls[hidden] {
display: none;
}
.slider__indicators {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 0.5rem;
}
.slider__arrow,
.slider__indicator {
min-width: 2.75rem;
min-height: 2.75rem;
font: inherit;
cursor: pointer;
}
.slider__indicator[aria-current="true"] {
font-weight: 700;
text-decoration: underline;
text-underline-offset: 0.25em;
}
.slider__track:focus-visible,
.slider__arrow:focus-visible,
.slider__indicator:focus-visible {
outline: 0.2rem solid currentColor;
outline-offset: 0.2rem;
}
.slider__arrow:disabled,
.slider__indicator:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.slider__status {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
@media (prefers-reduced-motion: reduce) {
.slider__track {
scroll-behavior: auto;
}
}
The result is responsive because no fixed slide width is stored in either CSS or JavaScript. A slide is always one current track width, whether that width is 320px, 50rem, or something between them.
aspect-ratio keeps each image box consistent, while object-fit: cover crops files whose proportions differ. The HTML width and height attributes give the browser the image ratio before the file arrives, and CSS scales that reserved shape to the available width.
This is also where native input survives. Touch dragging, trackpad scrolling, keyboard scrolling when the track receives focus, and dragging the scrollbar all move the same scroll container. JavaScript does not replace any of them.
For other movement primitives, CSS-animations and JavaScript animations cover the browser’s two broader animation paths.
Add Previous, Next, and Indicator Controls
One initializer receives one carousel root and queries only inside it. That boundary is what makes multiple instances independent.
The current index is not calculated from a cached image width. Instead, the initializer compares the logical starting edge of the track with every slide and picks the closest one. Layout remains the source of truth after resizing and manual scrolling.
Add this complete JavaScript after the markup, or load it with a deferred script:
function createSlider(root) {
const track = root.querySelector('[data-slider-track]');
const slides = Array.from(root.querySelectorAll('[data-slide]'));
const controls = root.querySelector('[data-slider-controls]');
const previousButton = root.querySelector('[data-slider-previous]');
const nextButton = root.querySelector('[data-slider-next]');
const indicators = Array.from(
root.querySelectorAll('[data-slider-indicator]')
);
const status = root.querySelector('[data-slider-status]');
if (
!track ||
!controls ||
!previousButton ||
!nextButton ||
!status
) {
return;
}
const slideCount = slides.length;
let currentIndex = -1;
let animationFrame = 0;
let scrollEndTimer = 0;
function nearestSlideIndex() {
if (slideCount === 0) {
return -1;
}
const trackRect = track.getBoundingClientRect();
const isRtl = getComputedStyle(track).direction === 'rtl';
const trackEdge = isRtl ? trackRect.right : trackRect.left;
let nearestIndex = 0;
let nearestDistance = Infinity;
slides.forEach((slide, index) => {
const slideRect = slide.getBoundingClientRect();
const slideEdge = isRtl ? slideRect.right : slideRect.left;
const distance = Math.abs(slideEdge - trackEdge);
if (distance < nearestDistance) {
nearestDistance = distance;
nearestIndex = index;
}
});
return nearestIndex;
}
function updateControls(index, announce = false) {
currentIndex = index;
const hasSeveralSlides = slideCount > 1;
previousButton.disabled = !hasSeveralSlides;
nextButton.disabled = !hasSeveralSlides;
indicators.forEach((button, indicatorIndex) => {
const isCurrent = indicatorIndex === currentIndex;
button.setAttribute('aria-current', String(isCurrent));
if (isCurrent) {
button.setAttribute('aria-disabled', 'true');
} else {
button.removeAttribute('aria-disabled');
}
button.disabled = indicatorIndex >= slideCount;
});
if (!announce) {
return;
}
if (currentIndex === -1) {
status.textContent = 'No slides available';
return;
}
const caption = slides[currentIndex]
.querySelector('figcaption')
?.textContent
.trim();
status.textContent = caption
? `Slide ${currentIndex + 1} of ${slideCount}: ${caption}`
: `Slide ${currentIndex + 1} of ${slideCount}`;
}
function synchronizeFromLayout() {
updateControls(nearestSlideIndex());
}
function queueSynchronization() {
if (animationFrame !== 0) {
cancelAnimationFrame(animationFrame);
}
animationFrame = requestAnimationFrame(() => {
animationFrame = 0;
synchronizeFromLayout();
});
}
function wrappedIndex(index) {
return ((index % slideCount) + slideCount) % slideCount;
}
function moveTo(index) {
if (slideCount < 2) {
return;
}
const destination = wrappedIndex(index);
const reduceMotion = window.matchMedia(
'(prefers-reduced-motion: reduce)'
).matches;
slides[destination].scrollIntoView({
behavior: reduceMotion ? 'instant' : 'smooth',
block: 'nearest',
inline: 'start',
});
}
previousButton.addEventListener('click', () => {
synchronizeFromLayout();
moveTo(currentIndex - 1);
});
nextButton.addEventListener('click', () => {
synchronizeFromLayout();
moveTo(currentIndex + 1);
});
indicators.forEach((button, index) => {
button.addEventListener('click', () => {
if (button.getAttribute('aria-disabled') === 'true') {
return;
}
moveTo(index);
});
});
function announceCurrentSlide() {
clearTimeout(scrollEndTimer);
scrollEndTimer = 0;
updateControls(nearestSlideIndex(), true);
}
function queueAnnouncement() {
clearTimeout(scrollEndTimer);
scrollEndTimer = setTimeout(announceCurrentSlide, 150);
}
track.addEventListener('scroll', queueSynchronization, {
passive: true,
});
if ('onscrollend' in track) {
track.addEventListener('scrollend', announceCurrentSlide);
} else {
track.addEventListener('scroll', queueAnnouncement, {
passive: true,
});
}
window.addEventListener('resize', queueSynchronization);
updateControls(nearestSlideIndex(), true);
if (slideCount > 0) {
controls.hidden = false;
}
}
if (typeof document !== 'undefined') {
document
.querySelectorAll('[data-slider]')
.forEach(createSlider);
}
Clicking Next begins by reading the current layout. If the visible slide is index 2 in a three-slide carousel, moveTo(3) wraps to 0. Moving backward from 0 sends -1 through the same expression and produces 2.
Then scrollIntoView() moves the chosen slide into its ancestor track. inline: "start" aligns the slide with the beginning of the horizontal viewport, while block: "nearest" avoids an unnecessary vertical jump. Motion is smooth unless the device reports a preference for reduced motion.
The code never asks how many pixels wide a slide was during initialization. There is no stale 400px assumption to break after a responsive layout change.
Keep State Synchronized After Swipes
A slider has more inputs than its buttons. A reader can swipe the track, use a trackpad, drag the scrollbar, resize the page, or zoom until the track receives a new width.
That is why currentIndex follows layout. During a scroll, queueSynchronization() schedules one position check for the next animation frame. Repeated scroll events replace the pending check instead of running a full slide search for every event.
When the nearest slide changes, updateControls() changes the indicator state:
- The matching indicator receives
aria-current="true"andaria-disabled="true". - The other indicators receive
aria-current="false"and havearia-disabledremoved.
The live status reports the position and caption after scrollend. Where that event is unavailable, a short debounce waits for scrolling to stop.
The same update handles the edge cases. With no slides, the index is -1, both arrow buttons are disabled, and the controls remain hidden. With one slide, the controls appear, the arrows are disabled, and the sole indicator remains focusable but is marked aria-disabled="true". With several slides, both arrows remain available because navigation wraps rather than stopping at either end.
This is a toy on purpose, but the state rule scales: the active slide is the slide nearest the track’s logical starting edge. Button clicks request movement; geometry decides what actually became current.
The resize listener uses the same synchronization path. It does not move to a cached offset, so a narrower container cannot leave the indicators describing the old position.
Make Motion and Controls Accessible
ARIA names the component and its parts, but it does not repair missing semantics or broken interaction. The HTML starts with an ordered list, figures, captions, and native buttons, then adds carousel-specific names.
The carousel container has a visible label through aria-labelledby. Its aria-roledescription="carousel" supplies the more specific description, while each native list item contains a group with aria-roledescription="slide" and a meaningful name such as Blue cove, slide 2 of 3.
Previous and next are buttons because they perform actions. Their accessible labels describe the result, and each aria-controls value points to the track it moves. The numbered indicator text remains visible, while its aria-label matches the accessible name of the slide it selects.
Normal document order already moves focus through the buttons. The script does not capture Tab, and clicking an indicator does not force focus into the newly visible slide.
Motion needs its own rule. The CSS media query removes smooth scrolling when prefers-reduced-motion: reduce matches, and the JavaScript makes the same choice for scrollIntoView(). CSS and JavaScript agree.
The example does not autoplay. That is the safer default because the reader controls every change.
If you add automatic rotation, treat it as a separate optional feature with these rules:
- Add a native button that stops and starts rotation.
- Stop rotation when keyboard focus enters the carousel.
- Stop rotation while the pointer hovers over the carousel.
- Do not restart after either pause until the reader deliberately starts it again.
A timer must never become the active-state authority. Scrolling still determines the current slide, whether movement began with a timer, a button, or a swipe.
The focus rules here build on ordinary browser behavior. Styles and classes explains the DOM and CSS tools behind state-driven styling, while The Browser Platform develops these APIs as part of the complete browser model.
Optimize and Test the Finished Slider
Responsive images finish the implementation. Each img has intrinsic width and height, a srcset of available files, and a sizes hint describing its rendered width. The browser can reserve space before the file loads and select an appropriate source.
The first image uses loading="eager" because it is initially visible. Later images use loading="lazy", which lets their loading wait until they approach the viewport. Captions remain ordinary text below each image.
Now test the component through every input it claims to support:
- Use
Tabto reach every button, then activate each one with the keyboard. - Move past the first and last slides in both directions and confirm wrapping.
- Swipe on a touch device and check that the indicator and status follow.
- Scroll with a trackpad and drag the native scrollbar with a mouse.
- Resize the viewport while the second slide is active.
- Enable reduced motion and confirm that slide changes are instant.
- Disable JavaScript and confirm that the focused track can be scrolled with the keyboard in every supported browser.
- Test the layout in both left-to-right and right-to-left directions.
- Test a slider with one slide and another with no slides.
- Put two sliders on one page and confirm that each set of controls moves only its own track.
- Check the carousel name, slide names, button labels, and status with a screen reader.
The no-JavaScript test catches the architectural mistake first. If the images disappear with the script, the component is not progressively enhanced. If a swipe leaves the wrong indicator selected, state is still tied to buttons instead of layout.
For another small project built around visible state, JavaScript Counter: Build One Step by Step follows the same pattern of one state source and several synchronized outputs.