Image Slideshow JavaScript: Build It From Scratch
If three landscape images look correct only after JavaScript loads, a script failure can stop the controls, leave the first image covering the others, and hide the remaining captions with them.
A reliable JavaScript image slideshow starts with readable HTML, stores one current index, renders every navigation path through one function, and adds accessible controls, optional autoplay, responsive images, and cleanup without hiding the original content when scripting fails.
What We Are Building
A slideshow presents a collection of images one at a time. The terms slideshow, slider, and carousel describe the same kind of interface in this guide, though other components sometimes use “slider” for an input control.
The finished component has five parts:
- The slide list contains figures that remain readable without JavaScript.
- The previous and next buttons move one position and wrap at both ends.
- The slide-picker buttons jump to a particular image.
- The autoplay button lets the user start or stop rotation.
- The slideshow state holds the current zero-based index and an optional timer.
JavaScript adds the one-slide presentation after the document is ready. This is progressive enhancement: the page begins as useful content, then scripting adds the interactive behavior.
Manual navigation remains the default. Nothing moves until the user requests it, and autoplay never starts as a side effect of loading the page.
Every input changes the same currentIndex. Previous, next, picker, and timer actions all pass through one goTo function, and one render function updates the slides, picker state, position text, and adjacent-image loading.
One index controls the whole component.
This example uses the same state-and-render split that works well in larger JavaScript animation systems, but the implementation stays dependency-free. Save the next three blocks as index.html, slideshow.css, and slideshow.js in one directory. Create an images directory and add 640-, 960-, and 1440-pixel-wide versions of the cliffs, cove, and lighthouse images using the filenames shown in the HTML, or substitute your own files and update each src and srcset.
Write the Semantic HTML
Start with an ordinary section containing a heading and a list of figures. The controls carry the hidden attribute because they do nothing until JavaScript initializes them:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Coastal route slideshow</title>
<link rel="stylesheet" href="slideshow.css">
<script src="slideshow.js" defer></script>
</head>
<body>
<main>
<section
class="slideshow"
data-slideshow
data-label="Coastal walking route"
data-interval="5000"
>
<h2>Coastal walking route</h2>
<ol class="slideshow__slides" data-slides>
<li class="slideshow__slide">
<figure>
<div class="slideshow__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: 52rem) 50rem, calc(100vw - 2rem)"
width="1440"
height="810"
loading="eager"
fetchpriority="high"
alt="A narrow path following grass-covered cliffs above the sea"
>
<p class="slideshow__error" data-image-error hidden>
The cliff photograph could not be loaded.
</p>
</div>
<figcaption>Cliff path, the first section of the route</figcaption>
</figure>
</li>
<li class="slideshow__slide">
<figure>
<div class="slideshow__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: 52rem) 50rem, calc(100vw - 2rem)"
width="1440"
height="810"
loading="lazy"
alt="A sheltered blue cove between two rocky headlands"
>
<p class="slideshow__error" data-image-error hidden>
The cove photograph could not be loaded.
</p>
</div>
<figcaption>Blue cove, halfway along the route</figcaption>
</figure>
</li>
<li class="slideshow__slide">
<figure>
<div class="slideshow__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: 52rem) 50rem, calc(100vw - 2rem)"
width="1440"
height="810"
loading="lazy"
alt="A white lighthouse beyond a field at sunset"
>
<p class="slideshow__error" data-image-error hidden>
The lighthouse photograph could not be loaded.
</p>
</div>
<figcaption>Lighthouse, the final stop on the route</figcaption>
</figure>
</li>
</ol>
<div
class="slideshow__controls"
data-controls
role="group"
aria-label="Slideshow controls"
hidden
>
<button type="button" data-autoplay>
Start autoplay
</button>
<button type="button" data-previous>Previous slide</button>
<p class="slideshow__position" data-position></p>
<button type="button" data-next>Next slide</button>
<div
class="slideshow__pickers"
data-pickers
role="group"
aria-label="Choose a slide"
></div>
</div>
</section>
</main>
</body>
</html>
Without JavaScript, the ordered list shows all three figures in document order and the unusable controls stay hidden. The captions remain attached to their images, while each alt value describes what the image itself contains.
The HTML does not contain carousel roles yet. Those roles describe an interactive widget, so JavaScript adds them only after the interaction exists.
Previous, next, and autoplay are native button elements. They enter the normal Tab sequence, work with keyboard activation, and do not need inline onclick attributes.
The empty data-pickers container is deliberate. JavaScript knows how many slides exist, so it generates exactly one picker for each slide and gives every button an accessible name.
Make the Slideshow Responsive With CSS
The unenhanced list needs ordinary document layout. The enhanced version changes to a single-slide viewport only after JavaScript adds slideshow--enhanced:
* {
box-sizing: border-box;
}
body {
margin: 0;
color: #172033;
background: #f4f6fa;
font-family: system-ui, sans-serif;
line-height: 1.5;
}
main {
width: min(100% - 2rem, 50rem);
margin-inline: auto;
padding-block: 3rem;
}
.slideshow__slides {
display: grid;
gap: 1.5rem;
margin: 0;
padding: 0;
list-style: none;
}
.slideshow figure {
margin: 0;
}
.slideshow__media {
display: grid;
place-items: center;
aspect-ratio: 16 / 9;
overflow: hidden;
border-radius: 0.75rem;
background: #dfe4ec;
}
.slideshow__media img {
width: 100%;
height: 100%;
object-fit: cover;
}
.slideshow__error {
margin: 1rem;
text-align: center;
}
.slideshow figcaption {
padding-block: 0.65rem;
}
.slideshow--enhanced .slideshow__slide--active {
animation: slideshow-fade 300ms ease-out;
}
.slideshow__controls {
display: grid;
grid-template-columns: auto 1fr auto;
gap: 0.75rem;
align-items: center;
margin-top: 1rem;
}
.slideshow__controls[hidden] {
display: none;
}
.slideshow__controls button {
min-height: 2.75rem;
padding: 0.55rem 0.8rem;
border: 2px solid #46556f;
border-radius: 0.45rem;
color: #172033;
background: #ffffff;
font: inherit;
cursor: pointer;
}
.slideshow__controls button:hover {
background: #e8edf5;
}
.slideshow__controls button:focus-visible {
outline: 3px solid #8a3ffc;
outline-offset: 3px;
}
.slideshow__position {
margin: 0;
text-align: center;
}
.slideshow__pickers {
display: flex;
grid-column: 1 / -1;
gap: 0.5rem;
justify-content: center;
}
.slideshow__pickers button {
min-width: 2.75rem;
}
.slideshow__pickers button[aria-current="true"] {
color: #ffffff;
border-color: #26334a;
background: #26334a;
}
[data-autoplay] {
grid-column: 1 / -1;
justify-self: center;
}
[data-autoplay]:disabled {
cursor: not-allowed;
opacity: 0.65;
}
@keyframes slideshow-fade {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.slideshow--enhanced .slideshow__slide--active {
animation: none;
}
}
@media (max-width: 34rem) {
.slideshow__controls {
grid-template-columns: 1fr 1fr;
}
.slideshow__position {
grid-column: 1 / -1;
grid-row: 1;
}
.slideshow__pickers,
[data-autoplay] {
grid-column: 1 / -1;
}
}
The aspect-ratio container gives every slide the same visible shape. object-fit: cover fills that shape consistently, so a portrait image and a landscape image do not keep changing the component’s height. Cropping is the price.
The images still carry intrinsic width and height values in HTML. Those dimensions give the browser an aspect ratio before each file arrives, which helps it reserve the correct space instead of moving later content after loading.
The active picker has both a visual fill and aria-current="true". Color is not the only state: the attribute exposes the same current choice to accessibility tools. This deliberately differs from the WAI-ARIA carousel example, which uses aria-disabled="true"; here, the current picker remains an available button, and the screen-reader test below verifies that its current state is announced.
Keyboard focus receives a separate outline through :focus-visible. The fade is brief, and the reduced-motion media query removes it when the device requests less nonessential motion. CSS animations covers the animation rules themselves.
The CSS does not hide any slide by default. JavaScript owns that change.
Build the Slideshow State and Controls
Now add the canonical JavaScript implementation. Every selector begins at one slideshow root, so adding another element with data-slideshow creates another independent instance:
function createSlideshow(root) {
const slidesList = root.querySelector("[data-slides]");
const slides = [...root.querySelectorAll(".slideshow__slide")];
const controls = root.querySelector("[data-controls]");
const previousButton = root.querySelector("[data-previous]");
const nextButton = root.querySelector("[data-next]");
const autoplayButton = root.querySelector("[data-autoplay]");
const pickers = root.querySelector("[data-pickers]");
const position = root.querySelector("[data-position]");
const motionQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
const controller = new AbortController();
const { signal } = controller;
if (
!slidesList ||
!controls ||
!previousButton ||
!nextButton ||
!autoplayButton ||
!pickers ||
!position ||
slides.length === 0
) {
return null;
}
let currentIndex = 0;
let timer = null;
let rotationRequested = false;
let hoverPaused = false;
const requestedInterval = Number(root.dataset.interval);
const interval = Number.isFinite(requestedInterval)
? Math.max(requestedInterval, 1000)
: 5000;
function wrap(index) {
return ((index % slides.length) + slides.length) % slides.length;
}
function handleImageFailure(image) {
const error = image
.closest(".slideshow__media")
?.querySelector("[data-image-error]");
image.hidden = true;
if (error) {
error.hidden = false;
}
}
function prepareImages() {
slides.forEach((slide) => {
const image = slide.querySelector("img");
if (!image) {
return;
}
image.addEventListener(
"error",
() => handleImageFailure(image),
{ signal }
);
if (image.complete && image.naturalWidth === 0) {
handleImageFailure(image);
}
});
}
function decodeAdjacentImages() {
const adjacentIndexes = new Set([
wrap(currentIndex - 1),
wrap(currentIndex + 1),
]);
adjacentIndexes.forEach((index) => {
const image = slides[index].querySelector("img");
if (image && !image.complete) {
void image.decode().catch(() => {
// the error listener shows the fallback
});
}
});
}
function render() {
slides.forEach((slide, index) => {
const isCurrent = index === currentIndex;
slide.hidden = !isCurrent;
slide.classList.toggle("slideshow__slide--active", isCurrent);
});
[...pickers.children].forEach((picker, index) => {
if (index === currentIndex) {
picker.setAttribute("aria-current", "true");
} else {
picker.removeAttribute("aria-current");
}
});
position.textContent =
`Slide ${currentIndex + 1} of ${slides.length}`;
decodeAdjacentImages();
}
function goTo(index) {
currentIndex = wrap(index);
render();
}
function updateRotationInterface() {
const isRotating = timer !== null;
if (motionQuery.matches) {
autoplayButton.textContent =
"Autoplay unavailable with reduced motion";
autoplayButton.disabled = true;
} else {
autoplayButton.textContent = rotationRequested
? "Stop autoplay"
: "Start autoplay";
autoplayButton.disabled = false;
}
slidesList.setAttribute(
"aria-live",
isRotating ? "off" : "polite"
);
}
function syncRotation() {
if (timer !== null) {
window.clearInterval(timer);
timer = null;
}
if (motionQuery.matches) {
rotationRequested = false;
}
const canRotate =
rotationRequested &&
!hoverPaused &&
!document.hidden &&
!motionQuery.matches;
if (canRotate) {
timer = window.setInterval(() => {
goTo(currentIndex + 1);
}, interval);
}
updateRotationInterface();
}
function stopAfterFocus() {
if (!rotationRequested) {
return;
}
rotationRequested = false;
syncRotation();
}
function buildPickers() {
slides.forEach((slide, index) => {
const picker = document.createElement("button");
picker.type = "button";
picker.textContent = String(index + 1);
picker.setAttribute(
"aria-label",
`Show slide ${index + 1} of ${slides.length}`
);
picker.addEventListener(
"click",
() => goTo(index),
{ signal }
);
pickers.append(picker);
});
}
function addSemantics() {
root.classList.add("slideshow--enhanced");
root.setAttribute("role", "region");
root.setAttribute("aria-roledescription", "carousel");
root.setAttribute(
"aria-label",
root.dataset.label || "Image slideshow"
);
slidesList.setAttribute("aria-atomic", "false");
slides.forEach((slide, index) => {
slide.setAttribute("role", "group");
slide.setAttribute("aria-roledescription", "slide");
slide.setAttribute(
"aria-label",
`${index + 1} of ${slides.length}`
);
});
controls.hidden = false;
}
previousButton.addEventListener(
"click",
() => goTo(currentIndex - 1),
{ signal }
);
nextButton.addEventListener(
"click",
() => goTo(currentIndex + 1),
{ signal }
);
autoplayButton.addEventListener(
"click",
() => {
rotationRequested = !rotationRequested;
syncRotation();
},
{ signal }
);
root.addEventListener(
"focusin",
stopAfterFocus,
{ signal }
);
root.addEventListener(
"pointerenter",
() => {
hoverPaused = true;
syncRotation();
},
{ signal }
);
root.addEventListener(
"pointerleave",
() => {
hoverPaused = false;
syncRotation();
},
{ signal }
);
document.addEventListener(
"visibilitychange",
syncRotation,
{ signal }
);
motionQuery.addEventListener(
"change",
syncRotation,
{ signal }
);
addSemantics();
buildPickers();
prepareImages();
render();
syncRotation();
return {
destroy() {
controller.abort();
if (timer !== null) {
window.clearInterval(timer);
timer = null;
}
slides.forEach((slide) => {
slide.hidden = false;
slide.classList.remove("slideshow__slide--active");
slide.removeAttribute("role");
slide.removeAttribute("aria-roledescription");
slide.removeAttribute("aria-label");
});
pickers.replaceChildren();
controls.hidden = true;
autoplayButton.textContent = "Start autoplay";
autoplayButton.disabled = false;
slidesList.removeAttribute("aria-live");
slidesList.removeAttribute("aria-atomic");
root.classList.remove("slideshow--enhanced");
root.removeAttribute("role");
root.removeAttribute("aria-roledescription");
root.removeAttribute("aria-label");
},
};
}
window.slideshows = [
...document.querySelectorAll("[data-slideshow]"),
]
.map(createSlideshow)
.filter(Boolean);
currentIndex is the only navigation state. goTo wraps the requested position and then calls render, whether the request came from an arrow button, a picker, or the timer.
The wrapping expression adds slides.length before taking the remainder. From the first slide, goTo(-1) becomes the final index. From the final slide, the next index becomes 0.
render performs the visible work. It reveals one slide, updates every picker’s current state, writes the visible position, and asks the neighboring images to decode.
The picker listeners close over their own zero-based index. They still use goTo, so direct selection cannot drift away from previous and next navigation.
Each call to createSlideshow keeps its variables inside that instance. Queries for slides and controls start from root, not document, while the initial query discovers every root on the page. Two slideshows can therefore hold different indexes and timers.
The returned destroy method ends the timer, removes all listeners registered with the shared AbortSignal, deletes generated pickers and accessibility attributes, and restores the original figure list. This matters when a client-side page removes or replaces the component.
The pattern uses ordinary functions, arrays, DOM queries, and events. JavaScript Fundamentals covers those pieces before they are combined into a component.
Add Safe Autoplay and Accessibility
Automatic rotation is optional and off by default. The autoplay button changes rotationRequested, while syncRotation decides whether the timer may currently run.
Those are different questions. A pointer hovering over the carousel creates a temporary pause, so leaving can resume a rotation the user requested. Keyboard focus is different: focusin clears the request, and rotation remains stopped until the user explicitly starts it again.
Moving the page into the background also clears the active timer. When the document becomes visible again, rotation resumes only if the user’s request still exists.
A reduced-motion preference stops rotation and disables its start button. The CSS removes the fade at the same time. The user can still move between slides manually, so reduced motion does not remove access to any image.
The JavaScript adds role="region" and aria-roledescription="carousel" to the initialized root. Each slide becomes a named group with aria-roledescription="slide" and a position such as 2 of 3.
When autoplay runs, the slide list uses aria-live="off". This prevents every automatic change from becoming an unsolicited announcement. When rotation stops, the value changes to polite, allowing manual slide changes to be communicated without interrupting current speech.
The picker for the visible slide receives aria-current="true". These pickers are ordinary buttons rather than tabs, so the code does not invent arrow-key behavior. Tab and Shift+Tab follow the page’s normal focus order, while Enter or Space activates the focused button.
Native buttons provide the mechanics, but labels still matter. “Previous slide,” “Next slide,” and “Show slide 2 of 3” tell the user what each control does without relying on its visual position.
ARIA describes the component. It does not repair missing captions, weak image alternatives, invisible focus, unpredictable movement, or untested keyboard behavior.
For components that need more elaborate transitions between page states, The View Transitions API provides a different browser-managed model. This slideshow keeps its motion in one small CSS animation.
Load Images Efficiently and Test It
The first slide uses loading="eager" because it is the initial visible content. Later images use loading="lazy", but this three-slide demo prepares both of them during its first render because each is adjacent to the current slide. In a longer gallery, distant lazy images can remain deferred until they become neighbors.
Each image also provides three candidates through srcset. The width descriptors tell the browser the intrinsic width of each file, while sizes describes how wide the image is expected to appear in the layout. The browser can then choose a suitable candidate.
The width and height attributes are present even though CSS makes the image responsive. They establish the file’s aspect ratio before its bytes arrive, while CSS scales that ratio into the available width.
After each render, decodeAdjacentImages finds the previous and next slides and calls decode() for images that are not complete. The returned promise settles after decoding succeeds or fails. A caught rejection prevents a failed image from producing an unhandled promise rejection, while the image’s error listener displays the matching text fallback.
Only the neighbors are prepared. A long gallery does not need every distant image decoded because the user might never reach it.
Now test the component as behavior, not as a screenshot:
- Wraparound: select the first slide, press Previous, and confirm that the last slide appears. Press Next and confirm that the first returns.
- Repeated input: press Next rapidly and select the same picker more than once. The visible slide, position text, and current picker must agree.
- Keyboard use: reach every control with Tab and Shift+Tab, activate each with the keyboard, and confirm that focus never jumps without a request.
- Screen readers: check the carousel name, slide position, button names, current-picker state, manual-change announcements, and silence during autoplay with actual assistive technology.
- Autoplay: start and permanently stop rotation with pointer, keyboard, and assistive-technology activation. Start it again, hover the component, leave it, and then move keyboard focus inside. Hover should pause temporarily; focus should stop rotation until another explicit start.
- Reduced motion: enable the device preference and confirm that the fade disappears, autoplay stops, and manual controls remain available.
- Hidden tabs: start autoplay, switch tabs, and confirm that the timer stops doing background work until the page becomes visible.
- Multiple instances: duplicate the complete
data-slideshowsection with different images. Navigation in one instance must not change the other. - Disabled JavaScript: block or rename
slideshow.js. All figures and captions must remain readable, while the controls remain hidden. - Slow and failed images: throttle the network, confirm that reserved image space remains stable, and break one URL to verify its text fallback.
A responsive layout also needs narrow and wide viewport checks. The controls should reflow without covering the caption, while object-fit: cover keeps each media frame consistent.
The finished component now survives more than the happy path. JavaScript adds the slideshow behavior; the original images remain ordinary page content underneath.