Skip to content
Advanced85 min build target7 min guide

Image carousel

A swipeable, auto-advancing carousel with correct looping and reduced-motion behavior.

Image carousel interface reference

HOW TO USE THIS CHALLENGE

  1. 1. Read the briefClarify decisions before coding.
  2. 2. Build from memoryUse the 85-minute target.
  3. 3. Study the guideCompare architecture, tests, and trade-offs.

REQUIREMENTS

  • Advance slides via next/previous controls, swipe/drag gesture, and optional auto-play.
  • Loop seamlessly from the last slide back to the first (and vice versa).
  • Pause auto-play on hover/focus and while the user is actively dragging.
  • Show position indicators (dots) that are also directly clickable to jump to a slide.
  • Preload the next/previous image so navigation doesn't show a blank frame.

EDGE CASES

  • Looping past the last slide must animate forward, not snap backward through every slide.
  • A drag gesture that ends without crossing the swipe threshold should snap back to the current slide, not accidentally advance.
  • Auto-play must stop entirely once the user interacts, or clearly resume only after a pause, not fight the user's manual navigation.
  • Rapidly clicking 'next' repeatedly shouldn't desync the visible slide from the indicator dots.

ACCESSIBILITY

  • Honor prefers-reduced-motion by disabling auto-play and non-essential slide-transition animation.
  • Mark the carousel region with role="region" and an aria-label, and each slide's visibility state should be reflected so assistive tech doesn't read hidden slides.
  • Next/previous controls and dots must be real, keyboard-operable buttons with clear accessible names (e.g. 'Go to slide 3 of 6'), not bare divs with click handlers.

SUGGESTED APPROACH

  • Track currentIndex and derive transform/translate purely from it; for seamless looping, clone the first/last slide at the opposite end and jump instantly (without transition) once the clone's transition finishes.
  • Implement drag with pointer events, tracking delta and committing to next/previous/snap-back based on a distance or velocity threshold on pointer-up.
  • Gate auto-play behind both a 'not hovered/focused' and a 'not currently dragging' condition, restarting the interval cleanly on each slide change rather than fighting a single long-lived interval.

EVALUATION RUBRIC

  • Looping is visually seamless in both directions, not a hard jump or reverse-scan.
  • Drag/swipe threshold and snap-back behavior feel correct and don't fight auto-play.
  • prefers-reduced-motion is honored by disabling non-essential motion.
  • Controls and indicators are real, labeled, keyboard-operable buttons.

01Understand the product before coding

Learning goals

  • Advance slides via next/previous controls, swipe/drag gesture, and optional auto-play.
  • Loop seamlessly from the last slide back to the first (and vice versa).
  • Pause auto-play on hover/focus and while the user is actively dragging.
  • Show position indicators (dots) that are also directly clickable to jump to a slide.

Decisions to state aloud

  • Looping past the last slide must animate forward, not snap backward through every slide.
  • A drag gesture that ends without crossing the swipe threshold should snap back to the current slide, not accidentally advance.
  • Auto-play must stop entirely once the user interacts, or clearly resume only after a pause, not fight the user's manual navigation.
  • Rapidly clicking 'next' repeatedly shouldn't desync the visible slide from the indicator dots.

02State model and invariants

Keep logical slide index separate from the rendered track index used for cloned loop slides. Interaction mode records idle, dragging, or animating so rapid controls cannot start conflicting transitions. Autoplay eligibility is derived from user preference, visibility, focus, hover, and interaction.

TypeScript
type CarouselState = {  logicalIndex: number; trackIndex: number;  mode: 'idle' | 'dragging' | 'animating'; dragX: number;  interacted: boolean; hovered: boolean; focusWithin: boolean;};const mayAutoplay = !reducedMotion && !state.interacted && !state.hovered && !state.focusWithin;

03Component architecture

  1. 01Carousel owns logical navigation and autoplay policy.
  2. 02Track renders cloned boundaries and handles transition completion.
  3. 03Slide hides noncurrent content from interaction and accessibility.
  4. 04Controls and Indicators are labelled buttons.
  5. 05usePointerSwipe owns capture, delta, velocity, and cancellation.

04Reference implementation walkthrough

Step 1

Separate logical and track indexes

Render last clone, real slides, then first clone. Animate into a clone, and on transition end disable transition and jump the track index to the matching real slide while logical index remains correct.

TypeScript
function settle(trackIndex: number, count: number) {  if (trackIndex === 0) return { logical: count - 1, track: count };  if (trackIndex === count + 1) return { logical: 0, track: 1 };  return { logical: trackIndex - 1, track: trackIndex };}

Step 2

Implement pointer capture

Capture the pointer, update a drag transform without committing index, and use distance plus velocity threshold on release. pointercancel restores the current slide.

TSX
function onPointerDown(e: React.PointerEvent) {  e.currentTarget.setPointerCapture(e.pointerId);  start.current = { x: e.clientX, time: performance.now() };  dispatch({ type: 'dragStarted' });}function onPointerUp(e: React.PointerEvent) {  commitSwipe(e.clientX - start.current.x, performance.now() - start.current.time);}

Step 3

Make autoplay subordinate to the user

Do not autoplay with reduced motion. Pause for hover, focus, hidden documents, or drag; after explicit navigation, stop or resume only under a stated policy.

Step 4

Expose one slide at a time

Name the region and controls, label positions, remove hidden slide controls from Tab order, and announce user navigation without turning every autoplay tick into disruptive speech.

SOLComplete solution, explained simply

Build it yourself first. This is one correct implementation, not the only one — read it top to bottom, then compare the shape of your version.

We are building a swipeable image slider that loops seamlessly, plays automatically until you touch it, and works with the keyboard. The one big idea: keep two numbers. The logical index is which real slide you are on (for the dots and the announcement). The track index points into a slightly longer strip that has a copy of the last slide glued to the front and a copy of the first glued to the end, so sliding past an edge is a normal slide, and then we snap invisibly.

Carousel.tsx
import { useEffect, useReducer, useRef, useState } from "react";
type Slide = { id: string; src: string; alt: string };type State = { trackIndex: number; animating: boolean; paused: boolean };type Action =  | { type: "next" } | { type: "prev" }  | { type: "settled"; count: number }  | { type: "pause" } | { type: "resume" };
function reducer(state: State, action: Action): State {  switch (action.type) {    case "next":      return state.animating ? state : { ...state, trackIndex: state.trackIndex + 1, animating: true };    case "prev":      return state.animating ? state : { ...state, trackIndex: state.trackIndex - 1, animating: true };    case "settled":      // if we animated onto a clone, jump instantly to the matching real slide      if (state.trackIndex === 0) return { ...state, trackIndex: action.count, animating: false };      if (state.trackIndex === action.count + 1) return { ...state, trackIndex: 1, animating: false };      return { ...state, animating: false };    case "pause":      return { ...state, paused: true };    case "resume":      return { ...state, paused: false };  }}
const reducedMotion = () =>  typeof window !== "undefined" &&  window.matchMedia("(prefers-reduced-motion: reduce)").matches;
export function Carousel({ slides, autoplayMs = 4000 }: { slides: Slide[]; autoplayMs?: number }) {  const count = slides.length;  const [state, dispatch] = useReducer(reducer, { trackIndex: 1, animating: false, paused: false });  const [dragX, setDragX] = useState(0);  const startX = useRef<number | null>(null);
  const logicalIndex =    state.trackIndex === 0 ? count - 1    : state.trackIndex === count + 1 ? 0    : state.trackIndex - 1;
  // Autoplay: off for reduced motion or a hidden tab, paused while the user is involved.  useEffect(() => {    if (state.paused || reducedMotion() || document.hidden) return;    const timer = setInterval(() => dispatch({ type: "next" }), autoplayMs);    return () => clearInterval(timer);  }, [state.paused, autoplayMs]);
  function onPointerDown(event: React.PointerEvent) {    if (state.animating) return;    event.currentTarget.setPointerCapture(event.pointerId);    startX.current = event.clientX;    dispatch({ type: "pause" });  }  function onPointerMove(event: React.PointerEvent) {    if (startX.current !== null) setDragX(event.clientX - startX.current);  }  function onPointerUp(event: React.PointerEvent) {    if (startX.current === null) return;    const delta = event.clientX - startX.current;    const threshold = event.currentTarget.clientWidth * 0.25;    startX.current = null;    setDragX(0);    if (delta <= -threshold) dispatch({ type: "next" });    else if (delta >= threshold) dispatch({ type: "prev" });    dispatch({ type: "resume" });  }
  const track = [slides[count - 1], ...slides, slides[0]]; // clone, reals, clone  const offset = -state.trackIndex * 100;
  return (    <section      aria-roledescription="carousel"      aria-label="Gallery"      onMouseEnter={() => dispatch({ type: "pause" })}      onMouseLeave={() => dispatch({ type: "resume" })}      onFocusCapture={() => dispatch({ type: "pause" })}      onBlurCapture={() => dispatch({ type: "resume" })}    >      <div style={{ overflow: "hidden" }}>        <div          style={{            display: "flex",            transform: "translateX(calc(" + offset + "% + " + dragX + "px))",            transition: state.animating ? "transform 0.4s ease" : "none",          }}          onTransitionEnd={() => dispatch({ type: "settled", count })}          onPointerDown={onPointerDown}          onPointerMove={onPointerMove}          onPointerUp={onPointerUp}        >          {track.map((slide, i) => (            <div              key={i}              role="group"              aria-roledescription="slide"              aria-hidden={i !== state.trackIndex}              style={{ flex: "0 0 100%" }}            >              <img                src={slide.src}                alt={i === state.trackIndex ? slide.alt : ""}                loading={i === state.trackIndex ? "eager" : "lazy"}              />            </div>          ))}        </div>      </div>
      <button type="button" aria-label="Previous slide" onClick={() => dispatch({ type: "prev" })}>\u2039</button>      <button type="button" aria-label="Next slide" onClick={() => dispatch({ type: "next" })}>\u203a</button>
      <p role="status" aria-live="polite" className="sr-only">        Slide {logicalIndex + 1} of {count}      </p>    </section>  );}

How each part works

Two indexes: logical and track

logicalIndex is 'which of the N real slides am I on' — that is what the announcement uses. trackIndex points into a strip of N + 2 elements: index 0 is a copy of the last slide, 1..N are the real slides, N + 1 is a copy of the first. Sliding to index 0 or N + 1 is a normal animation.

The clones make looping seamless

Going next from the last real slide animates to the clone of the first (which looks identical to the first). When that animation ends, 'settled' turns the CSS transition off and jumps trackIndex to the real first slide in the same render — so the transform changes with no transition and the viewer sees nothing.

animating blocks input mid-transition

The 'next' and 'prev' reducer cases return the state unchanged if animating is already true. Without this, mashing the arrow keys starts several overlapping transitions and the dots and the visible slide fall out of sync.

Autoplay is subordinate to the user

The autoplay effect does not even start an interval if reduced motion is set or the tab is hidden. Hovering, focusing anything inside, or starting a drag dispatches 'pause', which stops the interval. Leaving or blurring resumes it. The user is always in charge.

Pointer events cover mouse, touch, and pen

One set of handlers with setPointerCapture. During the drag we only move a visual dragX offset — no slide change. On release, if the drag passed a quarter of the width we commit next or prev; otherwise dragX resets to 0 and the slide springs back.

Only the current slide is real to assistive tech

Every non-current slide gets aria-hidden and an empty alt, so a screen reader only sees one image at a time. The current image loads eagerly; the rest are lazy. A polite status line announces 'Slide 2 of 5' on navigation without narrating every autoplay tick.

Why this is correct

  • Separating the logical slide index from the clone-aware track index is what keeps the dots and announcements correct while allowing seamless looping.
  • Animate into a boundary clone, then snap to the real slide with the transition disabled in the same render.
  • Ignore navigation while a transition is running, or rapid input desynchronizes the visible slide from the state.
  • Autoplay must never run under reduced motion and must pause for hover, focus, drag, and hidden tabs.
  • Expose exactly one slide to assistive technology at a time, and announce navigation without narrating autoplay.

05Testing strategy

Critical behavior

  • Looping is visually seamless in both directions, not a hard jump or reverse-scan.
  • Drag/swipe threshold and snap-back behavior feel correct and don't fight auto-play.
  • prefers-reduced-motion is honored by disabling non-essential motion.
  • Controls and indicators are real, labeled, keyboard-operable buttons.

Failure and boundary cases

  • Looping past the last slide must animate forward, not snap backward through every slide.
  • A drag gesture that ends without crossing the swipe threshold should snap back to the current slide, not accidentally advance.
  • Auto-play must stop entirely once the user interacts, or clearly resume only after a pause, not fight the user's manual navigation.
  • Rapidly clicking 'next' repeatedly shouldn't desync the visible slide from the indicator dots.

Accessibility

  • Honor prefers-reduced-motion by disabling auto-play and non-essential slide-transition animation.
  • Mark the carousel region with role="region" and an aria-label, and each slide's visibility state should be reflected so assistive tech doesn't read hidden slides.
  • Next/previous controls and dots must be real, keyboard-operable buttons with clear accessible names (e.g. 'Go to slide 3 of 6'), not bare divs with click handlers.

06Performance and production hardening

  • Load the current image eagerly and preload only adjacent likely images.
  • Reserve dimensions to prevent layout shift.
  • Use transform for track motion and avoid promoting excessive layers.
  • Pause timers in hidden documents.

QAInterview questions and model answers

Answer aloud first. Then open the model answer and compare state ownership, failure handling, accessibility, and trade-offs—not exact wording.

Model answer

Logical index describes product state while track index includes clones needed for seamless animation. Conflating them breaks indicators and boundary jumps.

Primary references

Ready to build it?

Implement the brief above in your own environment against a timer close to 85 minutes, then self-review against the rubric before moving on.

Back to all briefs →