Skip to main content

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.

Result0px
New tab (opens in a new tab)
Download
<!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

  1. 01

    Put the slides on a track inside a window

    A slider is two boxes, one inside the other. The track, a <ul> with id="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">
  2. 02

    Describe each photo with alt, and caption it

    The alt attribute is text that describes an image for people who can't see it, and it's what screen readers read out. width and height tell 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>
  3. 03

    Say where you are with aria-live

    The 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>
  4. 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-label gives 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

  1. 05

    Crop the track with overflow: hidden

    By default, anything too big for its box spills out and stays visible. overflow: hidden crops 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;
    }
  2. 06

    Animate the move with transition

    transition tells the browser to animate a CSS property from its old value to its new one, instead of changing it instantly. transition: transform 0.4s ease means that whenever JavaScript changes the track's transform, it glides there over 0.4 seconds. The prefers-reduced-motion media 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;
    	}
    }
  3. 07

    Crop photos to one shape with object-fit

    Photos come in different shapes, and stretching them to one size squashes them. aspect-ratio: 16 / 10 gives every photo the same shape, based on its width. object-fit: cover then 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;
    }
  4. 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-gradient fades from transparent at 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;
    }
  5. 09

    Make small dots easy to tap

    The dot looks small, but the button is 1.75rem across, which is easier to hit with a finger. The trick is in the background: adding content-box to background paints the colour only inside the padding, not under it. The 0.5rem of 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;
    }
  6. 10

    Style the current dot from aria-current

    aria-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

  1. 11

    Keep the slide number in a variable

    A variable is a named place to keep a value. current holds which slide is showing, counted from 0, and it's let because it changes. The page doesn't know the slide number; this variable does. The previous and next buttons ask for current - 1 or current + 1, and showSlide() 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));
  2. 12

    Wrap round with %

    % (modulo) gives the remainder after dividing: 5 % 4 is 1. With 4 slides, going past the last slide asks for index 4, and 4 % 4 is 0, so you land on the first slide. Going back from the first asks for -1, so the code adds slides.length first: (-1 + 4) % 4 is 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;
  3. 13

    Move the track with translateX

    transform: 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 with current * 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}`;
  4. 14

    Hide slides you can't see with inert

    The window only hides the other slides from view; they're still in the page, so screen readers would read all four photos. The inert property makes an element and everything inside it unreachable: screen readers skip it and Tab can't focus it. slide.inert = slideIndex !== current sets it to true on 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;
    	});
  5. 15

    Jump straight to a slide with the dots

    forEach runs 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 calls showSlide() with its own dotIndex. Inside showSlide(), another loop sets every dot's aria-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.

  1. Challenge 01

    Let the left and right arrow keys move between photos.

  2. Challenge 02

    Add swiping on touch screens with pointer events.

  3. Challenge 03

    Show small thumbnails under the slider instead of dots.

Finished Image Slider?

Mark it done to track your progress.

You finished Image Slider!

0 of 15 projects done.

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.

Read the article (opens in a new tab)