Project 06 · Intermediate
Image Slider
Four landscape photos you move through one at a time. Arrow buttons step through them, dots jump straight to any photo, and a live count tells everyone, including screen reader users, which slide is showing.
- 4 HTML ideas
- 6 CSS ideas
- 5 JavaScript ideas
Why build it
Moving content with transforms and keeping several controls in step lets you build product photo galleries, property listings with a photo per room, onboarding screens that step through a few slides, and testimonial sliders. The idea underneath, one number that the track, the count, and the dots all read from, is how you'll build most interactive pages from now on.
Skills you'll earn
- Slide content smoothly with CSS transforms and transitions
- Loop from the last item back to the first with the remainder operator, %
- Hide content that's off screen from keyboards and screen readers, with inert
- Keep several controls in step by reading them all from one variable
- Show photos well, with alt text, captions, and object-fit
01
Try it
Play with it first, then read the code that makes it work.
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Image Slider</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<main class="gallery">
<h1 class="gallery-title">Places to wander</h1>
<section class="slider" aria-label="Photos">
<!-- The window shows one slide; the track holds them all in a row. -->
<div class="slider-window">
<!-- A list, so screen readers say how many photos there are. -->
<ul class="slider-track" id="track">
<li class="slide">
<figure>
<!-- alt describes the photo; width and height reserve its space. -->
<img
src="images/fjord.jpg"
alt="A deep blue fjord between rocky cliffs, with hikers resting on a ledge in front"
width="960"
height="600"
/>
<figcaption>
<strong>Fjord</strong>
<span class="photo-credit">Photo by Alexey Topolyanskiy on Unsplash</span>
</figcaption>
</figure>
</li>
<li class="slide">
<figure>
<img
src="images/waterfall.jpg"
alt="A waterfall pouring into a mossy green gorge surrounded by pine forest"
width="960"
height="600"
/>
<figcaption>
<strong>Waterfall</strong>
<span class="photo-credit">Photo by Andrew Coelho on Unsplash</span>
</figcaption>
</figure>
</li>
<li class="slide">
<figure>
<img
src="images/snow-camp.jpg"
alt="Yellow tents half buried in snow at a camp, with snowy peaks behind"
width="960"
height="600"
/>
<figcaption>
<strong>Mountain camp</strong>
<span class="photo-credit">Photo by Wolfgang Lutz on Unsplash</span>
</figcaption>
</figure>
</li>
<li class="slide">
<figure>
<img
src="images/granite-cliffs.jpg"
alt="Tall granite cliffs rising above pine trees and a calm river"
width="960"
height="600"
/>
<figcaption>
<strong>Granite cliffs</strong>
<span class="photo-credit">Photo by Christian Joudrey on Unsplash</span>
</figcaption>
</figure>
</li>
</ul>
</div>
<div class="slider-controls">
<button
type="button"
class="slider-button"
id="previous"
aria-label="Previous slide"
>
←
</button>
<!-- aria-live reads out "Slide 2 of 4" after each move. -->
<p class="slider-count" id="count" aria-live="polite">Slide 1 of 4</p>
<button
type="button"
class="slider-button"
id="next"
aria-label="Next slide"
>
→
</button>
</div>
<!-- One dot per slide; aria-current marks the one showing. -->
<div class="slider-dots" id="dots">
<button type="button" class="dot" aria-label="Go to slide 1"></button>
<button type="button" class="dot" aria-label="Go to slide 2"></button>
<button type="button" class="dot" aria-label="Go to slide 3"></button>
<button type="button" class="dot" aria-label="Go to slide 4"></button>
</div>
</section>
</main>
<script src="script.js"></script>
</body>
</html>02
How it works
The ideas behind it, one at a time, each next to the code that does it.
HTMLthe structure
- 01
Put the slides on a track inside a window
A slider is two boxes, one inside the other. The track, a
<ul>withid="track", holds every slide side by side in one long row. The window around it,.slider-window, is only one slide wide, so it shows a single photo at a time. Moving the track sideways changes which photo sits in the window. Using a list also lets screen readers say how many photos there are.<section class="slider" aria-label="Photos"> <!-- The window shows one slide; the track holds them all in a row. --> <div class="slider-window"> <!-- A list, so screen readers say how many photos there are. --> <ul class="slider-track" id="track"> - 02
Describe each photo with
alt, and caption itThe
altattribute is text that describes an image for people who can't see it, and it's what screen readers read out.widthandheighttell the browser the photo's shape before it downloads, so it saves the right space and the page doesn't jump when the photo appears.<figure>groups the photo with its<figcaption>, which holds the title and the photographer's credit.<li class="slide"> <figure> <!-- alt describes the photo; width and height reserve its space. --> <img src="images/fjord.jpg" alt="A deep blue fjord between rocky cliffs, with hikers resting on a ledge in front" width="960" height="600" /> <figcaption> <strong>Fjord</strong> <span class="photo-credit">Photo by Alexey Topolyanskiy on Unsplash</span> </figcaption> </figure> - 03
Say where you are with
aria-liveThe text "Slide 2 of 4" tells everyone where they are in the set. Sighted people see it change, but screen reader users won't know unless it's read out.
aria-live="polite"marks the paragraph as a live region: whenever its text changes, screen readers announce the new text once they finish what they're saying.<!-- aria-live reads out "Slide 2 of 4" after each move. --> <p class="slider-count" id="count" aria-live="polite">Slide 1 of 4</p> - 04
Add a dot button for each slide
The dots let you jump straight to any photo. Each one is a real
<button>, so you can reach it with Tab and press it with Enter or Space. A button normally gets its name from the text inside it, but these are empty, so screen readers would just say "button".aria-labelgives each one a name, such as "Go to slide 1".<!-- One dot per slide; aria-current marks the one showing. --> <div class="slider-dots" id="dots"> <button type="button" class="dot" aria-label="Go to slide 1"></button> <button type="button" class="dot" aria-label="Go to slide 2"></button> <button type="button" class="dot" aria-label="Go to slide 3"></button> <button type="button" class="dot" aria-label="Go to slide 4"></button> </div>
CSSthe look
- 05
Crop the track with
overflow: hiddenBy default, anything too big for its box spills out and stays visible.
overflow: hiddencrops it instead, so nothing outside the box's edges shows. Here the track is four slides wide but the window is only one, so the other three slides are cut off and only the slide lined up with the window shows through./* overflow: hidden crops the track, so only one slide shows through. */ .slider-window { overflow: hidden; border-radius: 1rem; } - 06
Animate the move with
transitiontransitiontells the browser to animate a CSS property from its old value to its new one, instead of changing it instantly.transition: transform 0.4s easemeans that whenever JavaScript changes the track'stransform, it glides there over 0.4 seconds. Theprefers-reduced-motionmedia query turns the transition off for people who've asked their device for less movement./* JavaScript moves the whole track sideways; transition animates it. */ .slider-track { display: flex; margin: 0; padding: 0; list-style: none; transition: transform 0.4s ease; } @media (prefers-reduced-motion: reduce) { .slider-track { transition: none; } } - 07
Crop photos to one shape with
object-fitPhotos come in different shapes, and stretching them to one size squashes them.
aspect-ratio: 16 / 10gives every photo the same shape, based on its width.object-fit: coverthen decides how the picture fills that shape: it scales the photo up until there's no gap and crops whatever spills over, so nothing looks stretched./* Scale the photo to the slide, and crop rather than squash it. */ .slide img { display: block; width: 100%; height: auto; aspect-ratio: 16 / 10; object-fit: cover; } - 08
Keep captions readable with a gradient
White text is hard to read on a bright part of a photo, and you can't know in advance which photos will be bright. A
linear-gradientfades fromtransparentat the top to a see-through black at the bottom, behind the caption. The caption always sits on a dark background, while most of the photo stays clear./* A dark fade behind the caption keeps white text readable on any photo. */ .slide figcaption { position: absolute; inset: auto 0 0; padding: 2.5rem 1rem 0.875rem; background: linear-gradient(transparent, rgb(0 0 0 / 0.7)); color: #ffffff; } - 09
Make small dots easy to tap
The dot looks small, but the button is
1.75remacross, which is easier to hit with a finger. The trick is in the background: addingcontent-boxtobackgroundpaints the colour only inside the padding, not under it. The0.5remof padding stays clear but still belongs to the button, so the visible dot is small while the tap area is generous./* The dot is small, but its padding makes it easy to tap. */ .dot { width: 1.75rem; height: 1.75rem; padding: 0.5rem; border: 0; border-radius: 999px; background: #d4d4da content-box; cursor: pointer; } - 10
Style the current dot from
aria-currentaria-current="true"is an attribute that tells screen readers which item in a set is the current one, here the dot for the slide that's showing. CSS can select on attributes too, so.dot[aria-current="true"]colours that dot in. One attribute does both jobs, so what screen readers hear and what you see can't disagree./* The attribute screen readers use also styles the current dot. */ .dot[aria-current="true"] { background-color: #1c1c22; }
JavaScriptthe behaviour
- 11
Keep the slide number in a variable
A variable is a named place to keep a value.
currentholds which slide is showing, counted from 0, and it'sletbecause it changes. The page doesn't know the slide number; this variable does. The previous and next buttons ask forcurrent - 1orcurrent + 1, andshowSlide()saves the new number and updates the page to match.// Which slide is showing, counted from 0. let current = 0; function showSlide(index) { // % wraps round: one past the last slide is the first again, and one // before the first is the last. Adding the length keeps it above zero. current = (index + slides.length) % slides.length; // Each slide is the track's full width, so index 2 sits at -200%. track.style.transform = `translateX(-${current * 100}%)`; countDisplay.textContent = `Slide ${current + 1} of ${slides.length}`; // Slides out of view are still in the page, so hide them from screen readers. slides.forEach((slide, slideIndex) => { slide.inert = slideIndex !== current; }); // The dots follow the same index as the slides. dots.forEach((dot, dotIndex) => { dot.setAttribute("aria-current", dotIndex === current); }); } previousButton.addEventListener("click", () => showSlide(current - 1)); nextButton.addEventListener("click", () => showSlide(current + 1)); - 12
Wrap round with
%%(modulo) gives the remainder after dividing:5 % 4is 1. With 4 slides, going past the last slide asks for index 4, and4 % 4is 0, so you land on the first slide. Going back from the first asks for -1, so the code addsslides.lengthfirst:(-1 + 4) % 4is 3, the last slide.// % wraps round: one past the last slide is the first again, and one // before the first is the last. Adding the length keeps it above zero. current = (index + slides.length) % slides.length; - 13
Move the track with
translateXtransform: translateX()moves an element sideways without affecting anything else in the layout. A percentage moves it by that share of its own width, and each slide is 100% wide. So showing the slide at index 2 means moving the track-200%, two slides to the left. The code builds that value withcurrent * 100.// Each slide is the track's full width, so index 2 sits at -200%. track.style.transform = `translateX(-${current * 100}%)`; countDisplay.textContent = `Slide ${current + 1} of ${slides.length}`; - 14
Hide slides you can't see with
inertThe window only hides the other slides from view; they're still in the page, so screen readers would read all four photos. The
inertproperty makes an element and everything inside it unreachable: screen readers skip it and Tab can't focus it.slide.inert = slideIndex !== currentsets it totrueon every slide except the one showing.// Slides out of view are still in the page, so hide them from screen readers. slides.forEach((slide, slideIndex) => { slide.inert = slideIndex !== current; }); - 15
Jump straight to a slide with the dots
forEachruns the same code once for each item in a list, and also hands you the item's position. Each dot gets a click listener that callsshowSlide()with its owndotIndex. InsideshowSlide(), another loop sets every dot'saria-current, so the dots, the arrow buttons and the count all show the same slide, however you got there.// The dots follow the same index as the slides. dots.forEach((dot, dotIndex) => { dot.setAttribute("aria-current", dotIndex === current); }); } previousButton.addEventListener("click", () => showSlide(current - 1)); nextButton.addEventListener("click", () => showSlide(current + 1)); // Each dot jumps straight to its own slide. dots.forEach((dot, dotIndex) => { dot.addEventListener("click", () => showSlide(dotIndex)); });
03
Wrap up
Track your progress, then go deeper or keep going.
Take it further
Once it works, try these on your own. Each one uses what you've just learned, plus one new idea.
- Challenge 01
Let the left and right arrow keys move between photos.
- Challenge 02
Add swiping on touch screens with pointer events.
- Challenge 03
Show small thumbnails under the slider instead of dots.
Finished Image Slider?
Mark it done to track your progress.
Go deeper
The full article explains each idea in more depth, why the code is written this way, and more than fits on this page.