A swipeable, auto-advancing carousel with correct looping and reduced-motion behavior.
Illustration coming soon
HOW TO USE THIS CHALLENGE
1. Read the briefClarify decisions before coding.
2. Build from memoryUse the 85-minute target.
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.
01Carousel owns logical navigation and autoplay policy.
02Track renders cloned boundaries and handles transition completion.
03Slide hides noncurrent content from interaction and accessibility.
04Controls and Indicators are labelled buttons.
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.
Capture the pointer, update a drag transform without committing index, and use distance plus velocity threshold on release. pointercancel restores the current slide.
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
1import{ useEffect, useReducer, useRef, useState }from"react";23type Slide={id: string; src: string; alt: string };4type State={trackIndex: number; animating: boolean; paused: boolean };5type Action=6|{type:"next"}|{type:"prev"}7|{type:"settled"; count: number }8|{type:"pause"}|{type:"resume"};910functionreducer(state:State,action:Action):State{11switch(action.type){12case"next":13return state.animating? state :{...state,trackIndex: state.trackIndex+1,animating:true};14case"prev":15return state.animating? state :{...state,trackIndex: state.trackIndex-1,animating:true};16case"settled":17// if we animated onto a clone, jump instantly to the matching real slide18if(state.trackIndex===0)return{...state,trackIndex: action.count,animating:false};19if(state.trackIndex=== action.count+1)return{...state,trackIndex:1,animating:false};20return{...state,animating:false};21case"pause":22return{...state,paused:true};23case"resume":24return{...state,paused:false};25}26}2728constreducedMotion=()=>29typeofwindow!=="undefined"&&30window.matchMedia("(prefers-reduced-motion: reduce)").matches;3132exportfunctionCarousel({ slides, autoplayMs =4000}:{slides:Slide[]; autoplayMs?: number }){33const count = slides.length;34const[state, dispatch]=useReducer(reducer,{trackIndex:1,animating:false,paused:false});35const[dragX, setDragX]=useState(0);36const startX = useRef<number |null>(null);3738const logicalIndex =39 state.trackIndex===0? count -140: state.trackIndex=== count +1?041: state.trackIndex-1;4243// Autoplay: off for reduced motion or a hidden tab, paused while the user is involved.44useEffect(()=>{45if(state.paused||reducedMotion()||document.hidden)return;46const timer =setInterval(()=>dispatch({type:"next"}), autoplayMs);47return()=>clearInterval(timer);48},[state.paused, autoplayMs]);4950functiononPointerDown(event:React.PointerEvent){51if(state.animating)return;52 event.currentTarget.setPointerCapture(event.pointerId);53 startX.current= event.clientX;54dispatch({type:"pause"});55}56functiononPointerMove(event:React.PointerEvent){57if(startX.current!==null)setDragX(event.clientX- startX.current);58}59functiononPointerUp(event:React.PointerEvent){60if(startX.current===null)return;61const delta = event.clientX- startX.current;62const threshold = event.currentTarget.clientWidth*0.25;63 startX.current=null;64setDragX(0);65if(delta <=-threshold)dispatch({type:"next"});66elseif(delta >= threshold)dispatch({type:"prev"});67dispatch({type:"resume"});68}6970const track =[slides[count -1],...slides, slides[0]];// clone, reals, clone71const offset =-state.trackIndex*100;7273return(74<section75 aria-roledescription="carousel"76 aria-label="Gallery"77 onMouseEnter={()=>dispatch({type:"pause"})}78 onMouseLeave={()=>dispatch({type:"resume"})}79 onFocusCapture={()=>dispatch({type:"pause"})}80 onBlurCapture={()=>dispatch({type:"resume"})}81>82<div style={{overflow:"hidden"}}>83<div84 style={{85display:"flex",86transform:"translateX(calc("+ offset +"% + "+ dragX +"px))",87transition: state.animating?"transform 0.4s ease":"none",88}}89 onTransitionEnd={()=>dispatch({type:"settled", count })}90 onPointerDown={onPointerDown}91 onPointerMove={onPointerMove}92 onPointerUp={onPointerUp}93>94{track.map((slide, i)=>(95<div96 key={i}97 role="group"98 aria-roledescription="slide"99 aria-hidden={i !== state.trackIndex}100 style={{flex:"0 0 100%"}}101>102<img103 src={slide.src}104 alt={i === state.trackIndex? slide.alt:""}105 loading={i === state.trackIndex?"eager":"lazy"}106/>107</div>108))}109</div>110</div>111112<button type="button" aria-label="Previous slide" onClick={()=>dispatch({type:"prev"})}>\u2039</button>113<button type="button" aria-label="Next slide" onClick={()=>dispatch({type:"next"})}>\u203a</button>114115<p role="status" aria-live="polite" className="sr-only">116Slide{logicalIndex +1}of{count}117</p>118</section>119);120}
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.