Image Slider in JavaScript: Accessible and Responsive
Three product photographs sat inside a slider that worked with a mouse and nowhere else. The arrows were spans, the dots had no names, a narrow screen stretched every image, and turning JavaScript off removed the entire gallery.
An accessible responsive image slider uses semantic content, native buttons, one JavaScript index, keyboard and swipe navigation, motion preferences, and responsive images that remain useful without JavaScript.
What we are building and how the slider works
An image slider, also called a carousel, presents an ordered set of slides within one viewing area. One slide is current, and controls move the viewing area through the set.
The component has five parts:
- The viewport is the visible window that clips the other slides.
- The track is the row that moves left and right.
- A slide contains one image and its caption.
- The previous and next buttons move one position in either direction.
- The slide-picker dots move directly to a named position.
JavaScript stores the active position in one zero-based variable named currentIndex. A value of 0 means the first slide, 1 means the second, and 2 means the third.
Every input changes that value. The arrow buttons, dot buttons, swipe gesture, and optional rotation never move the track independently. They call the same navigation function, which updates currentIndex and asks one render function to synchronize the interface.
That separation keeps the state small. The track position, current dot, accessible slide state, and visible position text are outputs of currentIndex, not competing copies of it.
The finished example also uses progressive enhancement. Before JavaScript runs, the page shows three figures in document order without carousel roles, a live region, or focusable slider controls. After initialization, JavaScript adds the interactive semantics and a class that turns those figures into a sliding track, then reveals the controls.
Write semantic slider HTML
Start with content that makes sense before it becomes interactive. An ordered list preserves the image sequence because each li keeps its list-item role, each figure connects an image with its caption, and the controls use real button elements:
<section
class="slider"
data-slider
data-label="Coastal walking route"
>
<button type="button" data-rotation hidden>
Start automatic slides
</button>
<div class="slider__viewport">
<ol class="slider__track" data-track>
<li class="slide">
<figure>
<div class="slide__media">
<img
src="images/cliffs-960.jpg"
srcset="
images/cliffs-640.jpg 640w,
images/cliffs-960.jpg 960w,
images/cliffs-1440.jpg 1440w
"
sizes="(min-width: 900px) 800px, 100vw"
width="1440"
height="810"
loading="eager"
alt="A narrow path following grass-covered cliffs above the sea"
>
<span class="slide__fallback" hidden>
The cliff photograph could not be loaded.
</span>
</div>
<figcaption>Cliff path, the first section of the route</figcaption>
</figure>
</li>
<li
class="slide"
id="slide-2"
role="group"
aria-roledescription="slide"
aria-label="2 of 3"
>
<figure>
<div class="slide__media">
<img
src="images/cove-960.jpg"
srcset="
images/cove-640.jpg 640w,
images/cove-960.jpg 960w,
images/cove-1440.jpg 1440w
"
sizes="(min-width: 900px) 800px, 100vw"
width="1440"
height="810"
loading="lazy"
alt="A sheltered blue cove between two rocky headlands"
>
<span class="slide__fallback" hidden>
The cove photograph could not be loaded.
</span>
</div>
<figcaption>Blue cove, halfway along the route</figcaption>
</figure>
</li>
<li
class="slide"
id="slide-3"
role="group"
aria-roledescription="slide"
aria-label="3 of 3"
>
<figure>
<div class="slide__media">
<img
src="images/lighthouse-960.jpg"
srcset="
images/lighthouse-640.jpg 640w,
images/lighthouse-960.jpg 960w,
images/lighthouse-1440.jpg 1440w
"
sizes="(min-width: 900px) 800px, 100vw"
width="1440"
height="810"
loading="lazy"
alt="A white lighthouse standing beyond a field at sunset"
>
<span class="slide__fallback" hidden>
The lighthouse photograph could not be loaded.
</span>
</div>
<figcaption>Lighthouse, the final stop</figcaption>
</figure>
</li>
</ol>
</div>
<div class="slider__controls" data-controls hidden>
<button
class="slider__arrow slider__arrow--previous"
type="button"
data-previous
aria-label="Show previous slide"
>
<span aria-hidden="true">‹</span>
</button>
<button
class="slider__arrow slider__arrow--next"
type="button"
data-next
aria-label="Show next slide"
>
<span aria-hidden="true">›</span>
</button>
<div class="slider__dots" role="group" aria-label="Choose a slide">
<button
type="button"
data-slide-picker="0"
aria-label="Show slide 1: Cliff path"
></button>
<button
type="button"
data-slide-picker="1"
aria-label="Show slide 2: Blue cove"
></button>
<button
type="button"
data-slide-picker="2"
aria-label="Show slide 3: Lighthouse"
></button>
</div>
<span class="slider__position" data-position aria-hidden="true">
1 of 3
</span>
</div>
<p class="visually-hidden" data-announcement hidden>
Slide 1 of 3: Cliff path, the first section of the route
</p>
</section>
<script src="slider.js" defer></script>
The controls start with hidden, so dead buttons do not appear if the script fails. Each photograph remains visible with its caption, and the first image loads eagerly while the later images can wait until the browser decides they are close enough to the viewport.
The width and height attributes describe the image proportions and let the browser reserve space. Each srcset uses width descriptors, so sizes tells the browser how much layout width the image is expected to occupy when it chooses a file.
Alt text describes information supplied by the photograph. If a caption already communicates everything meaningful in an image, use alt="" instead of repeating the same sentence twice.
Create the responsive sliding layout
The unenhanced rules keep the figures readable. Slider-specific positioning begins under .slider--ready, the class JavaScript adds only after it finds the required elements:
[hidden] {
display: none !important;
}
.slider {
max-width: 50rem;
margin-inline: auto;
color: #172033;
}
.slider__track {
margin: 0;
padding: 0;
list-style: none;
}
.slide figure {
margin: 0 0 1.5rem;
}
.slide__media {
position: relative;
aspect-ratio: 16 / 9;
overflow: hidden;
border-radius: 0.75rem;
background: #dbe2ea;
}
.slide img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.slide__fallback {
display: grid;
width: 100%;
height: 100%;
place-items: center;
padding: 1rem;
text-align: center;
}
.slide figcaption {
padding: 0.75rem 0.25rem;
}
.slider--ready {
position: relative;
touch-action: pan-y;
}
.slider--ready .slider__viewport {
overflow: hidden;
border-radius: 0.75rem;
}
.slider--ready .slider__track {
display: flex;
transition: transform 350ms ease;
will-change: transform;
}
.slider--ready .slide {
flex: 0 0 100%;
min-width: 0;
}
.slider--ready .slide figure {
margin: 0;
}
.slider--ready .slider__controls {
display: grid;
grid-template-columns: auto 1fr auto;
gap: 0.75rem;
align-items: center;
margin-top: 0.75rem;
}
.slider__arrow,
.slider__dots button,
[data-rotation] {
min-width: 2.75rem;
min-height: 2.75rem;
border: 1px solid currentColor;
background: white;
color: inherit;
cursor: pointer;
}
.slider__dots button[aria-disabled="true"] {
cursor: default;
opacity: 0.65;
}
.slider__arrow {
position: absolute;
z-index: 1;
top: calc(50% - 3rem);
border-radius: 999px;
font-size: 1.75rem;
}
.slider__arrow--previous {
left: 0.75rem;
}
.slider__arrow--next {
right: 0.75rem;
}
.slider__dots {
display: flex;
grid-column: 2;
gap: 0.5rem;
justify-content: center;
}
.slider__dots button {
min-width: 0.85rem;
min-height: 0.85rem;
padding: 0;
border-radius: 50%;
background: transparent;
}
.slider__dots button[aria-current="true"] {
background: currentColor;
}
.slider__position {
justify-self: end;
font-variant-numeric: tabular-nums;
}
[data-rotation] {
grid-column: 1 / -1;
justify-self: center;
padding-inline: 1rem;
border-radius: 0.35rem;
}
.slider :focus-visible {
outline: 0.2rem solid #075fd8;
outline-offset: 0.2rem;
}
.slide--image-error img {
display: none;
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
@media (max-width: 36rem) {
.slider__arrow {
top: calc(50% - 3.5rem);
}
.slider__position {
grid-column: 1 / -1;
justify-self: center;
}
}
@media (prefers-reduced-motion: reduce) {
.slider--ready .slider__track {
transition-duration: 0.001ms;
}
}
Each .slide occupies exactly 100% of the viewport, so moving the track by -100% reveals the second slide and -200% reveals the third. The container changes width with the page, but that relationship stays intact. Element size and scrolling covers the measurements behind more elaborate layouts.
aspect-ratio: 16 / 9 gives every media box the same shape. object-fit: cover fills that box and crops overflow, which keeps portrait and landscape source files from changing the slider height. Change it to contain when seeing the entire image matters more than filling the frame.
The transition belongs to CSS because the state change is a transform. CSS animations and JavaScript animations separate this kind of declarative movement from a timing loop controlled by JavaScript.
The reduced-motion query responds to a motion preference enabled in the device settings. Its rule makes the change effectively instant without removing navigation.
Add the JavaScript state and navigation
The script is organized in stages: query the elements, validate the required core, create state, render that state, connect navigation, then add swipe, optional rotation, and image-error handling. Spread syntax turns the queried slides and dots into arrays; optional chaining skips a missing optional element; dataset reads a data-* value; and the modulo expression wraps an index around either end.
The script initializes every [data-slider] on the page independently. If a slider is incomplete or contains no slides, the function returns and leaves the original figures alone:
let sliderInstanceCount = 0;
document.querySelectorAll('[data-slider]').forEach(initSlider);
function initSlider(slider) {
const track = slider.querySelector('[data-track]');
const slides = [...slider.querySelectorAll('.slide')];
const controls = slider.querySelector('[data-controls]');
const previousButton = slider.querySelector('[data-previous]');
const nextButton = slider.querySelector('[data-next]');
const rotationButton = slider.querySelector('[data-rotation]');
const position = slider.querySelector('[data-position]');
const announcement = slider.querySelector('[data-announcement]');
const dots = [...slider.querySelectorAll('[data-slide-picker]')];
if (
!track ||
slides.length === 0 ||
!controls ||
!previousButton ||
!nextButton ||
!position ||
!announcement
) {
return;
}
const instanceId = `slider-${++sliderInstanceCount}`;
slider.setAttribute('role', 'region');
slider.setAttribute('aria-roledescription', 'carousel');
slider.setAttribute(
'aria-label',
slider.dataset.label || 'Image carousel'
);
announcement.setAttribute('aria-live', 'polite');
slides.forEach((slide, index) => {
const slideId = `${instanceId}-slide-${index + 1}`;
const slideContent = slide.querySelector('figure');
slide.id = slideId;
slide.removeAttribute('role');
slide.removeAttribute('aria-roledescription');
slide.removeAttribute('aria-label');
dots[index]?.setAttribute('aria-controls', slideId);
if (slideContent) {
slideContent.setAttribute('role', 'group');
slideContent.setAttribute('aria-roledescription', 'slide');
slideContent.setAttribute(
'aria-label',
`${index + 1} of ${slides.length}`
);
}
});
let currentIndex = 0;
let rotationTimer = null;
let rotationRequested = false;
let pointerIsOver = false;
let pointerStart = null;
function wrapIndex(index) {
return ((index % slides.length) + slides.length) % slides.length;
}
function render({ announce = true } = {}) {
track.style.transform = `translateX(-${currentIndex * 100}%)`;
slides.forEach((slide, index) => {
const isCurrent = index === currentIndex;
slide.setAttribute('aria-hidden', String(!isCurrent));
slide.toggleAttribute('inert', !isCurrent);
});
dots.forEach((dot, index) => {
if (index === currentIndex) {
dot.setAttribute('aria-current', 'true');
dot.setAttribute('aria-disabled', 'true');
} else {
dot.removeAttribute('aria-current');
dot.removeAttribute('aria-disabled');
}
});
const visibleNumber = currentIndex + 1;
const caption =
slides[currentIndex].querySelector('figcaption')?.textContent.trim() ??
`Slide ${visibleNumber}`;
position.textContent = `${visibleNumber} of ${slides.length}`;
if (announce) {
announcement.textContent =
`Slide ${visibleNumber} of ${slides.length}: ${caption}`;
}
}
function goToSlide(index, options) {
currentIndex = wrapIndex(index);
render(options);
}
function nextSlide(options) {
goToSlide(currentIndex + 1, options);
}
function previousSlide(options) {
goToSlide(currentIndex - 1, options);
}
function pauseRotation() {
if (rotationTimer !== null) {
window.clearInterval(rotationTimer);
rotationTimer = null;
}
}
function stopRotation() {
pauseRotation();
rotationRequested = false;
if (rotationButton) {
rotationButton.textContent = 'Start automatic slides';
}
announcement.setAttribute('aria-live', 'polite');
}
function startRotation() {
if (!rotationButton) {
return;
}
pauseRotation();
rotationRequested = true;
rotationButton.textContent = 'Stop automatic slides';
announcement.setAttribute('aria-live', 'off');
if (pointerIsOver) {
return;
}
rotationTimer = window.setInterval(() => {
nextSlide({ announce: false });
}, 5000);
}
previousButton.addEventListener('click', () => {
stopRotation();
previousSlide();
});
nextButton.addEventListener('click', () => {
stopRotation();
nextSlide();
});
dots.forEach((dot) => {
dot.addEventListener('click', () => {
const targetIndex = Number(dot.dataset.slidePicker);
if (targetIndex === currentIndex) {
return;
}
stopRotation();
goToSlide(targetIndex);
});
});
rotationButton?.addEventListener('click', () => {
if (!rotationRequested) {
startRotation();
} else {
stopRotation();
}
});
slider.addEventListener('keydown', (event) => {
if (event.target !== slider) {
return;
}
if (event.key === 'ArrowLeft') {
event.preventDefault();
stopRotation();
previousSlide();
}
if (event.key === 'ArrowRight') {
event.preventDefault();
stopRotation();
nextSlide();
}
if (event.key === 'Home') {
event.preventDefault();
stopRotation();
goToSlide(0);
}
if (event.key === 'End') {
event.preventDefault();
stopRotation();
goToSlide(slides.length - 1);
}
});
slider.addEventListener('pointerdown', (event) => {
if (!event.isPrimary || event.target.closest('button')) {
return;
}
pointerStart = {
id: event.pointerId,
x: event.clientX,
y: event.clientY,
};
});
slider.addEventListener('pointerup', (event) => {
if (!pointerStart || event.pointerId !== pointerStart.id) {
return;
}
const distanceX = event.clientX - pointerStart.x;
const distanceY = event.clientY - pointerStart.y;
pointerStart = null;
const isHorizontal =
Math.abs(distanceX) > 50 &&
Math.abs(distanceX) > Math.abs(distanceY);
if (!isHorizontal) {
return;
}
stopRotation();
if (distanceX < 0) {
nextSlide();
} else {
previousSlide();
}
});
slider.addEventListener('pointercancel', () => {
pointerStart = null;
});
slider.addEventListener('focusin', (event) => {
if (event.target !== rotationButton) {
stopRotation();
}
});
slider.addEventListener('pointerenter', () => {
pointerIsOver = true;
if (rotationTimer !== null) {
stopRotation();
}
});
slider.addEventListener('pointerleave', () => {
pointerIsOver = false;
if (rotationRequested && rotationTimer === null) {
startRotation();
}
});
slides.forEach((slide) => {
const image = slide.querySelector('img');
const fallback = slide.querySelector('.slide__fallback');
const showFallback = () => {
slide.classList.add('slide--image-error');
image.hidden = true;
if (fallback) {
fallback.hidden = false;
}
};
image?.addEventListener('error', showFallback);
if (image?.complete && image.naturalWidth === 0) {
showFallback();
}
});
slider.classList.add('slider--ready');
controls.hidden = false;
announcement.hidden = false;
previousButton.disabled = slides.length < 2;
nextButton.disabled = slides.length < 2;
if (rotationButton) {
rotationButton.hidden = false;
rotationButton.disabled = slides.length < 2;
}
render({ announce: false });
}
The wrap formula handles both sides of the list:
((index % slideCount) + slideCount) % slideCount
With three slides, moving forward from index 2 produces 3, which wraps to 0. Moving backward from index 0 produces -1; the extra addition turns the negative remainder into index 2.
goToSlide is the only function that assigns a new position. nextSlide and previousSlide express direction, while render turns the resulting state into visible and accessible changes.
That boundary matters. Adding another input later, such as thumbnail images, requires one call to goToSlide. It does not require another version of the movement logic.
The script adds and removes a class through classList, a pattern covered further in Styles and classes.
Add dots, keyboard controls, and swipe gestures
Each dot carries its zero-based destination in data-slide-picker. Clicking the third dot passes 2 to goToSlide, and render moves the track, marks that dot with aria-current="true", updates the visible 3 of 3 label, and changes the live-region text.
The dots remain ordinary buttons. A keyboard user reaches each one with Tab and activates it with the button’s native keyboard behavior. The script does not replace Tab or Shift+Tab.
Swipe navigation uses Pointer Events, so the same handler can receive touch, pen, and mouse input. It records the starting coordinates and waits until contact ends before deciding whether the gesture counts.
A horizontal movement must exceed 50 pixels and be larger than the vertical movement. A short tap changes nothing, and a mostly vertical gesture remains page scrolling. The CSS declaration touch-action: pan-y makes that vertical intent explicit.
Swipe is an addition, not the only mobile control. The previous, next, and dot buttons remain visible and usable when a gesture is difficult or unavailable.
Make the carousel accessible and motion-safe
The WAI-ARIA carousel pattern describes a slide as one content container in a presented set. It distinguishes previous, next, rotation, and slide-picker controls, which are the same names used throughout this implementation.
The outer container has role="region", aria-roledescription="carousel", and the accessible label Coastal walking route. A carousel embedded inside a larger composite widget can use role="group" instead.
Each slide also uses role="group" and aria-roledescription="slide". Its label presents the zero-based program state as a one-based human position: 1 of 3, 2 of 3, or 3 of 3.
Native buttons supply focus behavior and activation semantics. The arrow glyphs are hidden from assistive technology because the button labels already say Show previous slide and Show next slide.
Inactive slides receive both aria-hidden="true" and inert. The first removes them from the accessibility tree, while the second prevents descendants from remaining interactive. That becomes necessary when a future slide contains a link, video control, or form field rather than an image alone.
The live region announces direct navigation politely. Automatic changes call render({ announce: false }) while aria-live is off, so rotation does not produce a new spoken message every five seconds.
The example includes that rotation control without enabling it automatically. Direct navigation stops rotation, focus entering another carousel control stops it, and pointer entry stops it. The visitor must activate Start automatic slides to begin again.
Reduced motion is separate from autoplay. The media query removes the visible sliding duration for someone who has requested less motion, while every button and state update still works. More elaborate effects can use The Web Animations API, but a transform transition is enough here.
This component uses browser features that also appear throughout The Browser Platform: semantic controls first, CSS for presentation, and JavaScript for the state that connects them.
Optimize and test the finished slider
The visible first image uses loading="eager". Later images use loading="lazy", which lets the browser defer them until they fall within its calculated distance from the viewport.
Every image supplies width, height, srcset, and sizes. The dimensions reserve a stable box, while the responsive candidates let the browser choose a resource for the expected display width. The fixed media ratio and object-fit then handle source photographs with different shapes.
An image error replaces the broken image with text inside the same media box. JavaScript failure takes a different path: controls stay hidden, no enhancement class is added, and the three figures remain readable.
Run the finished page through this manual test matrix. In browser developer tools, disable JavaScript, block one image URL, apply network throttling, emulate a narrow touch device, and emulate prefers-reduced-motion: reduce; also resize the page without emulation. Test the screen-reader row with NVDA and Firefox on Windows and with VoiceOver and Safari on an Apple device:
| Test | Expected result |
|---|---|
| JavaScript disabled | All figures, images, alt text, and captions remain in document order; slider controls stay hidden. |
| Keyboard only | Buttons work with their native keys, the rotation control is reached first, and Tab remains ordinary. |
| Screen reader | The carousel and slides have names, direct changes are announced politely, and inactive slides are unavailable. |
| Touch input | Horizontal swipes change slides; short and vertical gestures do not. |
| Narrow and wide viewports | The viewport follows its container, images keep the chosen ratio, and captions remain visible. |
| Reduced motion enabled | Slide changes happen without a visible travel animation. |
| Failed image request | The failed image disappears and its fallback message occupies the media box. |
| Slow image loading | The first image starts immediately, later images may be deferred, and reserved dimensions keep the layout stable. |
| Optional rotation | Rotation begins only after activation, stops on interaction, and does not announce automatic movement. |
One currentIndex still controls every result. That is the part to protect when the component gains thumbnails, more slides, or different transitions.