Skip to main content

Project 07 · Intermediate

Carousel

A row of fruit cards that moves along every few seconds. You'll make it play on a timer, step through it with buttons, and stop it moving whenever someone is reading, or has asked their device for less motion.

  • 2 HTML ideas
  • 2 CSS ideas
  • 6 JavaScript ideas

Why build it

Timers you can start and stop let you build rotating news headlines, a strip of client logos that scrolls on its own, a slideshow for a shop window screen, and an autosave that runs every few seconds. You'll also know how to pause moving content on hover and focus, and to stop it for people who've asked their device for less motion.

Skills you'll earn

  • Run code every few seconds with setInterval, and stop it with clearInterval
  • Scroll an element from JavaScript with scrollBy and scrollTo
  • Pause moving content while someone hovers over it or uses the keyboard inside it
  • Find out from JavaScript whether someone wants less motion, with prefers-reduced-motion

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>Carousel</title>
		<link rel="stylesheet" href="style.css" />
	</head>
	<body>
		<main class="shelf">
			<div class="shelf-header">
				<h1 class="shelf-title">Fruit of the week</h1>
				<div class="shelf-controls">
					<button
						type="button"
						class="control"
						id="previous"
						aria-label="Previous card"
					>
						←
					</button>
					<!-- Moving content needs a way to stop it; the label says what it'll do. -->
					<button type="button" class="control control-wide" id="play-pause">
						Pause
					</button>
					<button type="button" class="control" id="next" aria-label="Next card">
						→
					</button>
				</div>
			</div>

			<!-- A list, so screen readers say how many cards there are. -->
			<ul class="carousel" id="carousel" aria-label="Fruit" tabindex="0">
				<li class="card card-mango">
					<span class="card-emoji" aria-hidden="true">🥭</span>
					<h2 class="card-title">Mango</h2>
					<p class="card-text">Sweet, juicy, and best eaten over the sink.</p>
				</li>
				<li class="card card-pineapple">
					<span class="card-emoji" aria-hidden="true">🍍</span>
					<h2 class="card-title">Pineapple</h2>
					<p class="card-text">Sharp and sweet at once, with a crown on top.</p>
				</li>
				<li class="card card-watermelon">
					<span class="card-emoji" aria-hidden="true">🍉</span>
					<h2 class="card-title">Watermelon</h2>
					<p class="card-text">Mostly water, which is the whole point on a hot day.</p>
				</li>
				<li class="card card-orange">
					<span class="card-emoji" aria-hidden="true">🍊</span>
					<h2 class="card-title">Orange</h2>
					<p class="card-text">Peel it in one go and you win the morning.</p>
				</li>
				<li class="card card-banana">
					<span class="card-emoji" aria-hidden="true">🍌</span>
					<h2 class="card-title">Banana</h2>
					<p class="card-text">Comes in its own wrapper. Ready when it has freckles.</p>
				</li>
				<li class="card card-pawpaw">
					<span class="card-emoji" aria-hidden="true">🍈</span>
					<h2 class="card-title">Pawpaw</h2>
					<p class="card-text">Soft, orange, and full of tiny black seeds.</p>
				</li>
			</ul>
		</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

    Give moving content a Pause button

    Content that moves on its own makes the page hard to use for some people: it's distracting, and some can't read text that won't hold still. Accessibility guidelines say anything that moves for more than five seconds needs a way to stop it. The button's text says what pressing it will do: "Pause" while the cards are moving, "Play" while they're stopped.

    					<!-- Moving content needs a way to stop it; the label says what it'll do. -->
    					<button type="button" class="control control-wide" id="play-pause">
    						Pause
    					</button>
  2. 02

    Use a list for the cards

    The cards are a set of similar items, so they go in a <ul>. Screen readers then announce "list, 6 items" before reading them, so people know how much there is. The list itself scrolls, and a scrolling box isn't focusable by default. tabindex="0" adds it to the Tab order, so keyboard users can focus the row and scroll it with the arrow keys.

    			<!-- A list, so screen readers say how many cards there are. -->
    			<ul class="carousel" id="carousel" aria-label="Fruit" tabindex="0">
    				<li class="card card-mango">
    					<span class="card-emoji" aria-hidden="true">🥭</span>
    					<h2 class="card-title">Mango</h2>
    					<p class="card-text">Sweet, juicy, and best eaten over the sink.</p>
    				</li>

CSSthe look

  1. 03

    Scroll a row of cards with scroll snap

    overflow-x: auto makes a box scroll sideways when its content is wider than it is. scroll-snap-type: x mandatory makes the scroll settle on a snap point when it stops, and each card's scroll-snap-align: start makes its left edge one of those points. scroll-behavior: smooth animates the scrolls JavaScript asks for, and reduced motion sets it back to auto.

    /* A row of cards that scrolls sideways and snaps to the start of a card. */
    .carousel {
    	display: flex;
    	gap: 1rem;
    	margin: 0;
    	padding: 0;
    	overflow-x: auto;
    	scroll-snap-type: x mandatory;
    	scroll-behavior: smooth;
    	list-style: none;
    	border-radius: 1rem;
    }
    
    @media (prefers-reduced-motion: reduce) {
    	.carousel {
    		scroll-behavior: auto;
    	}
    }
  2. 04

    Size cards with flex and min()

    flex sets how a flex item grows, shrinks and how wide it starts. flex: 0 0 min(14rem, 80%) means: don't grow, don't shrink, and start at whichever is smaller, 14rem or 80% of the row. Because the cards can't shrink to fit, six of them are wider than the row, which is what gives it something to scroll.

    /* Each card is a fixed share of the row, so two or three show at once. */
    .card {
    	flex: 0 0 min(14rem, 80%);
    	padding: 1.5rem;
    	border-radius: 1rem;
    	scroll-snap-align: start;
    }

JavaScriptthe behaviour

  1. 05

    Measure one step with offsetWidth

    To move one card along, the code needs to know how far that is in pixels. offsetWidth gives an element's rendered width in pixels. The gap between cards is set in CSS, so getComputedStyle(carousel).columnGap reads it, and parseFloat turns the text "16px" into the number 16. One card's width plus one gap is exactly one step.

    // One step is one card's width plus the gap after it.
    function stepSize() {
    	const card = carousel.querySelector(".card");
    	const gap = parseFloat(getComputedStyle(carousel).columnGap);
    	return card.offsetWidth + gap;
    }
  2. 06

    Move the row with scrollBy and scrollTo

    scrollBy() scrolls a box by an amount from where it is now, and scrollTo() scrolls to an exact position. scrollNext() first checks whether the row is at its end: how far it's scrolled (scrollLeft) plus the visible width (clientWidth) reaches the full width (scrollWidth). If so, it goes back to the start with scrollTo({ left: 0 }); otherwise it moves one step.

    function scrollNext() {
    	// At the end, go back to the start instead of stopping.
    	const isAtEnd =
    		carousel.scrollLeft + carousel.clientWidth >= carousel.scrollWidth - 1;
    	if (isAtEnd) {
    		carousel.scrollTo({ left: 0 });
    	} else {
    		carousel.scrollBy({ left: stepSize() });
    	}
    }
    
    function scrollPrevious() {
    	carousel.scrollBy({ left: -stepSize() });
    }
  3. 07

    Repeat on a timer with setInterval

    setInterval(scrollNext, DELAY) asks the browser to call scrollNext over and over, waiting DELAY milliseconds (4 seconds) each time. Timers keep running until you stop them, so setInterval returns an id, kept in timer. clearInterval(timer) uses that id to stop that particular timer. DELAY is a const in capitals, a common way to mark a setting you don't change while the code runs.

    // How long each card stays before the next one, in milliseconds.
    const DELAY = 4000;
    
    // One step is one card's width plus the gap after it.
    function stepSize() {
    	const card = carousel.querySelector(".card");
    	const gap = parseFloat(getComputedStyle(carousel).columnGap);
    	return card.offsetWidth + gap;
    }
    
    function scrollNext() {
    	// At the end, go back to the start instead of stopping.
    	const isAtEnd =
    		carousel.scrollLeft + carousel.clientWidth >= carousel.scrollWidth - 1;
    	if (isAtEnd) {
    		carousel.scrollTo({ left: 0 });
    	} else {
    		carousel.scrollBy({ left: stepSize() });
    	}
    }
    
    function scrollPrevious() {
    	carousel.scrollBy({ left: -stepSize() });
    }
    
    // Don't start moving for people who've asked their device for less motion.
    let isPlaying = !window.matchMedia("(prefers-reduced-motion: reduce)").matches;
    // True while the pointer or keyboard focus is on the cards.
    let isHeld = false;
    // setInterval returns an id; clearInterval needs it to stop the timer.
    let timer = null;
    
    // One place decides whether the timer runs, so the rules can't clash.
    function updatePlayback() {
    	clearInterval(timer);
    	if (isPlaying && !isHeld) {
    		timer = setInterval(scrollNext, DELAY);
    	}
    	playPauseButton.textContent = isPlaying ? "Pause" : "Play";
    }
  4. 08

    Check prefers-reduced-motion in JavaScript

    People can ask their device for less movement on screen. CSS reads that with @media (prefers-reduced-motion: reduce), and window.matchMedia() asks the same question from JavaScript; .matches is true if they've asked. The ! flips it, so isPlaying starts false for them and the carousel starts paused. They can still press Play.

    // Don't start moving for people who've asked their device for less motion.
    let isPlaying = !window.matchMedia("(prefers-reduced-motion: reduce)").matches;
  5. 09

    Decide in one place whether it plays

    Two things can stop the timer: the Pause button, stored in isPlaying, and someone using the cards, stored in isHeld. If each one started and stopped the timer itself, one could restart what the other had stopped. Instead, both only change their own variable and call updatePlayback(), which clears the timer and starts a new one only when both say it can run.

    // True while the pointer or keyboard focus is on the cards.
    let isHeld = false;
    // setInterval returns an id; clearInterval needs it to stop the timer.
    let timer = null;
    
    // One place decides whether the timer runs, so the rules can't clash.
    function updatePlayback() {
    	clearInterval(timer);
    	if (isPlaying && !isHeld) {
    		timer = setInterval(scrollNext, DELAY);
    	}
    	playPauseButton.textContent = isPlaying ? "Pause" : "Play";
    }
    
    playPauseButton.addEventListener("click", () => {
    	isPlaying = !isPlaying;
    	updatePlayback();
    });
  6. 10

    Pause while someone is reading

    Cards that move while someone is reading them are frustrating. mouseenter and mouseleave fire when the pointer moves onto and off the row; focusin and focusout do the same for keyboard focus. Each pair calls hold() and release(), which set isHeld and call updatePlayback(), so the carousel holds still while someone is on it.

    // Hold still while someone is reading or using the cards.
    function hold() {
    	isHeld = true;
    	updatePlayback();
    }
    
    function release() {
    	isHeld = false;
    	updatePlayback();
    }
    
    carousel.addEventListener("mouseenter", hold);
    carousel.addEventListener("mouseleave", release);
    carousel.addEventListener("focusin", hold);
    carousel.addEventListener("focusout", release);

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

    Add dots that show which card is first in view.

  2. Challenge 02

    Pause the carousel while the browser tab is hidden, using the visibilitychange event.

  3. Challenge 03

    Let people choose the speed from a select.

Finished Carousel?

Mark it done to track your progress.

You finished Carousel!

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.

Article coming soon. See the series so far (opens in a new tab)