cf.completefrontend
My progress
SYSTEM DESIGN · #12

Design an image carousel

A reusable carousel that shows one or a few images at a time, for a product gallery, a home page hero, or the photos in a feed post. The hard parts are a layout that swipes smoothly, mounting only the slides that matter, loading images fast without layout shift, telling a swipe from a scroll, looping without glitches, autoplay that respects people, and the ARIA carousel pattern.

The question

Design an image carousel for our design system. It shows one image at a time, or a few side by side, and people move between them by swiping, by clicking arrows or dots, or with the keyboard. Think of the photo gallery on a product page, the rotating banner at the top of a store's home page, or a post with several photos in a social feed.

Three teams will use it first: the product page, where the first photo is the biggest thing on the screen; the marketing team, who want slides that change on their own; and the feed team, who show up to 20 photos per post and many posts per page. It has to load fast, swipe smoothly on cheap phones, and work for keyboard and screen reader users. Walk me through how you would build it.

Real-life examples: Amazon and Zalando product galleries, The hero banner on an online store home page, Multi-photo posts in Instagram and LinkedIn, Airbnb listing photos in search results, Netflix rows of posters

Requirements: what to ask before drawing anything

A carousel looks like the easiest component in the design system: a row of images and two arrows. That's the trap. It touches almost every hard topic in frontend work at once: layout and animation, image loading and Core Web Vitals, touch gestures, timers, and one of the trickier accessibility patterns. Interviewers like it because a weak answer stops at "a flex row with translateX" and a strong answer keeps finding real problems for 45 minutes.

So the first five minutes decide where to go deep. Ask questions whose answers change the design. The table lists the questions this guide asks, the answer it assumes, and what moves if the interviewer answers differently.

QuestionAnswer we assumeWhat it changes
What goes in a slide?Mostly one image. The hero also has a heading and a button on top of the image, and some slides hold a video poster.Slides must accept any content through slots, not just an image URL.
How many slides?Product gallery: 4 to 12. Hero: 3 to 6. Feed post: up to 20. One listing page has 100 photos.With 100 slides we can't mount every image. We need a render window.
How many carousels per page?One on the product page and the home page. A feed can have 30 posts with a carousel each.Each instance must be cheap when idle: no timers, no listeners on window, no images loaded off screen.
Is the first slide above the fold?Yes on the product page and the home page. It's the largest thing on screen, so it's the LCP element.The first image needs priority loading; the rest must wait.
Does it change slides on its own?Only the hero, and marketing wants it. Galleries and feed posts never do.Autoplay is opt-in, with pause rules and a pause button.
One slide at a time or several?One on phones. The product gallery shows one big image; a "related products" row shows 2 to 4 cards depending on width.Slides per view is a prop with breakpoints.
Does it loop?The hero loops. Galleries stop at the ends, so people know they've seen everything.Looping is a prop, and its edge cases need care.
Which devices and languages?Phones first (60% of traffic), desktops, touch, mouse, keyboard, screen readers; Arabic and Hebrew sites too.Pointer events, touch-action, the ARIA pattern, and right-to-left layout.
The anatomy of a carousel with 8 slides, showing slide 3. A wide track, moved with translate3d, holds one box per slide. Only the viewport, a window around slide 3, is visible; it clips the rest. Slides 2 and 4 are mounted next to it, slide 1 and slides 5 to 8 are empty boxes. Under the viewport: a Previous button, 8 dots with the third one current, a Next button, and a Pause button.track: moved with translate3dviewport1 empty2 mounted3 of 84 mounted5-8 emptyPreviousNextPauseonly with autoplay
The viewport has a fixed aspect ratio and overflow: clip; only the track moves. Slides near the current one are mounted, the rest are empty boxes that keep the math simple.

Functional requirements

  • Show one slide, or a set number of slides side by side, out of a list of any length.
  • Move between slides by swiping on touch screens, dragging with a mouse if the team turns it on, clicking previous and next buttons, clicking a dot or thumbnail, and with the keyboard.
  • Let each team decide what a slide contains: a plain image, an image with text and a button, or a video poster.
  • Let the page read and set the current slide, and tell the page when it changes, so a product page can sync the gallery with the color picker.
  • Optionally loop from the last slide back to the first.
  • Optionally advance on its own (autoplay), with a visible control to stop it.

Non-functional requirements, with numbers

Non-functional requirements say how well the component works. Give each one a number so it can be tested. "Smooth" can't be tested; "no dropped frames during a swipe on a mid-range phone" can.

  • Fast first image. When the first slide is the LCP element, LCP (Largest Contentful Paint, the time until the biggest element on screen is drawn) stays at or under 2.5 seconds at the 75th percentile on 4G.
  • No layout shift. The carousel reserves its exact height before any image arrives, so it adds 0 to CLS (Cumulative Layout Shift, a score of how much the page jumps; good is 0.1 or less).
  • Smooth swipes. The slide follows the finger with no dropped frames: 16.7 ms per frame on a 60 Hz screen, 8.3 ms on a 120 Hz one. Taps on the arrows paint within 100 ms, so INP (Interaction to Next Paint) stays far under the 200 ms that Google calls good.
  • Frugal. Off-screen slides cost nothing until they're close. A feed post with 20 photos downloads 2 or 3 of them until the user swipes.
  • Accessible. Meets WCAG 2.2 level AA and follows the WAI-ARIA Authoring Practices carousel pattern. Everything works with the keyboard alone, autoplay can be paused, and motion respects prefers-reduced-motion.
  • Small. Under about 8 KB of compressed JavaScript, because it ships on the home page.
  • International. Works in right-to-left languages, where "next" is on the left.

Out of scope, said out loud

Naming what you won't design shows judgment and gives the interviewer a chance to redirect you early.

  • Pinch to zoom and a full-screen viewer. That's the image lightbox, a separate component the gallery can open on click.
  • The image service that resizes and converts images. We assume a CDN that can return any width in AVIF, WebP, or JPEG from a URL like /img/abc.jpg?w=800. We define what we need from it.
  • Video playback inside slides. A slide can hold a poster image and a play button; the video player is its own component.
  • Choosing which slides to show or their order. The page passes the list in.

Check your understanding · Exercise 1

Which answers from the interviewer would change the design the most? Select all that apply.

What makes a carousel hard

Before picking any technique, name the properties of the problem that every later decision has to respect. For a carousel there are six. Each one becomes a chapter later in this guide, so this short list is also the map of the answer.

Six properties that drive the design

  1. It moves. During a swipe the track changes position on every frame, 60 to 120 times a second, while the finger is down. Any work that runs layout or React rendering per frame shows up as stutter on a cheap phone. Movement has to happen on the compositor, the part of the browser that moves already-painted layers on the GPU.
  2. It's image heavy, and most images are hidden. A product gallery with 8 photos at about 150 KB each is 1.2 MB, but the user sees one. A feed with 30 posts of 10 photos each would be 300 downloads if we were careless. The design must load the visible image first and fast, the neighbors next, and nothing else.
  3. It shares the touch surface with the page. The carousel sits inside a page that scrolls up and down. A finger that lands on a slide might mean "next photo" or "scroll the page". Guessing wrong in either direction is the most common complaint about carousels on phones.
  4. It has time in it. Slides animate between positions for 300 ms or so, and autoplay changes them on a timer. That gives the component states beyond "which index": idle, dragging, settling, playing, paused. Bugs live in the transitions between them, such as a timer firing in the middle of a drag.
  5. Most of its content is off screen. Hidden slides still exist in the DOM unless we remove them. If a hidden slide has a "Shop now" link, keyboard users can tab into something they can't see. Screen readers can read all 8 slides as one long blob. Hiding has to be deliberate.
  6. It's used in very different places. A one-image hero, a five-card product row, and a 20-photo feed post have different defaults for looping, autoplay, slides per view, and loading. One component has to serve all of them through its API, without three forks.

Notice what isn't on the list: data fetching. Unlike a feed or an autocomplete, a carousel usually gets its slides from the page as a prop. The server work is the image CDN, which we treat as a given. That's why this problem is mostly about the browser: rendering, input, loading, and accessibility.

It also helps to name the budget in numbers. A mid-range Android phone gives the main thread about 16 ms per frame at 60 Hz, and the browser needs a few of those milliseconds for itself. So the JavaScript that runs on each pointermove must stay well under 5 ms.

Memory is the second budget. A decoded 1600 by 1600 image takes 1600 x 1600 x 4 bytes, about 10 MB of memory, no matter how small the JPEG file was. Those two numbers explain most of the choices that follow.

Think first

A teammate says: "It's just display: flex and transform: translateX(-index * 100%). Ship it in an afternoon." Which of the six properties does that version break first on a real product page?

The component API: what teams pass in and what they get back

For a design system component, the API is the design. Every team will use it for years, and every prop is hard to remove once shipped. So start here, before layout or gestures: what goes in, what comes out, and who owns the current index.

The naive API is images: string[]. It works for exactly one team. The hero needs a heading and a button on each slide, the feed needs a video poster in slide 3, and the product page wants a thumbnail strip. The first big decision is how slides get into the component.

Decision How should slides get into the carousel?

A list of image URLs

images={["/a.jpg", "/b.jpg"]} and the carousel renders an img for each.

Pros

  • Smallest API. Easy to document.
  • The carousel controls every img, so it can set srcset, sizes, and loading attributes correctly.

Cons

  • Only images. The hero's text and button, a video poster, or a product card need new props each time, and the API grows into a mess of captions, links, overlays.

Children, one per slide

Carousel with Carousel.Slide children. The carousel counts and wraps its children.

Pros

  • Reads like HTML. Any content fits in a slide.

Cons

  • The carousel has to inspect children with Children.count and Children.map, which React's docs mark as fragile: a fragment or a wrapper component hides the real slides.
  • Every slide's element is created even if only 3 of 100 will mount.

Data plus a render function

items={photos} and renderSlide={(photo, info) => ...}. The carousel decides which indexes to render and calls the function only for those.

Pros

  • Any content fits, and the carousel still knows the count.
  • Lazy by design: with a render window, slide 87 is never rendered until it's near.
  • info passes state such as isActive and priority, so the slide can set fetchpriority on its own image.

Cons

  • A little less familiar than children.
  • The slide's author has to apply the loading hints. We help with a ready-made CarouselImage.

What we pick: Data plus a render function. It keeps the count and the render window inside the carousel, which we need for 100 slides, while letting each team put anything in a slide. We ship a CarouselImage helper that reads the info object and sets srcset, sizes, loading, and fetchpriority correctly, so the common case is still one line.

Who owns the current index: controlled and uncontrolled

Most pages don't care which slide is showing. They want to drop the carousel in and forget it. That's uncontrolled mode: the carousel keeps the index in its own state, and defaultIndex sets where it starts.

The product page is different. When the shopper picks "red" in the color picker, the gallery must jump to the first red photo, and when the shopper swipes to a red photo, the picker should follow. That's controlled mode: the page owns index and the carousel asks for changes through onIndexChange.

Support both, the same way a native input supports value and defaultValue. Inside, one small hook checks whether index was passed. If it was, the carousel never changes the index itself; it calls onIndexChange and waits for the new prop.

If the parent ignores the call, a swipe snaps back to where it was. That's correct: the parent is in charge. Switching between modes after mount is a bug in the caller, so log a warning in development, like React does for inputs.

ts
type ChangeReason = "swipe" | "button" | "dot" | "keyboard" | "autoplay";

type CarouselProps<T> = {
  items: readonly T[];
  renderSlide: (item: T, info: SlideInfo) => ReactNode;
  getKey: (item: T) => string;
  label: string;                     // "Product photos", read by screen readers
  index?: number;                    // controlled
  defaultIndex?: number;             // uncontrolled start
  onIndexChange?: (next: number, reason: ChangeReason) => void;
  onSettle?: (index: number) => void; // after the animation ends
  slidesPerView?: number | Record<string, number>; // 1, or { "0px": 1, "768px": 3 }
  loop?: boolean;                    // default false
  autoplay?: { intervalMs: number } | false;
  aspectRatio: string;               // "4 / 5", reserves the height
  renderControls?: (api: CarouselApi) => ReactNode; // custom arrows, dots, thumbs
};

type SlideInfo = { index: number; isActive: boolean; isNear: boolean; priority: boolean };
type CarouselApi = { index: number; count: number; next(): void; prev(): void; goTo(i: number): void };
The public props, trimmed to what matters for the design

Events and slots

Two events, not five. onIndexChange fires when the target index changes, with a reason. The reason matters more than it looks: analytics wants to count swipes and arrow clicks but not autoplay changes, and the product page only syncs the color picker for changes the user made.

onSettle fires when the animation ends, which is the right moment to start heavy work such as loading a 3D model for the new slide. Avoid a per-frame onScroll style event in the public API; teams will put setState in it and break the 16 ms budget.

Controls are a slot too. The default renders previous and next buttons and dots. The product page swaps in a thumbnail strip through renderControls, which receives the same CarouselApi the default controls use. Because the default controls are built on the public API, we know the API is complete: if our own dots can't be built with it, a team's thumbnails can't either.

TeamProps they passWhat they get
Product pageindex, onIndexChange, renderControls with thumbnails, aspectRatio="4 / 5"A gallery in sync with the color picker, no loop, no autoplay.
Home page heroloop, autoplay={{ intervalMs: 6000 }}, aspectRatio="16 / 6"A rotating banner with a pause button and the right live region settings.
Feed postdefaultIndex={0}, aspectRatio from the first photoA lazy carousel that costs almost nothing until the post is near the screen.
Related productsslidesPerView={{ "0px": 2, "768px": 4 }}A row of cards that moves one page at a time.

Check your understanding · Exercise 2

The product page passes index={selected} and onIndexChange={setSelected}. A shopper swipes from the photo at index 2 to the one at index 3. What should happen? Select all that apply.

Layout: scroll snap, a moving track, or one slide at a time

Now the core choice: what physically moves when the user goes to the next slide. There are three families, and real libraries use each of them.

Swiper and Embla move a track with transform. Many simple galleries use the browser's own scrolling with CSS scroll snap. Fading heroes show one slide at a time. Each is right somewhere, so weigh them against our six properties.

Three layouts compared. Scroll snap: the browser scrolls a row of slides with scrollLeft; it swipes natively, stays smooth when the main thread is busy, but gives little control. Transform track: our code moves a row with translate3d; full control, but we own the gestures. One at a time: only one slide is in the DOM and changes with an opacity fade; nothing to drag.scroll snap123scrollLeftbrowser swipessmooth when busylittle controltransform track123translate3dour code swipesfull controlwe own gesturesone at a time2opacity1 slide in DOMfade or swapnothing to drag
Scroll snap hands the gesture to the browser, the track hands it to our code, and one-at-a-time has no gesture at all. The guide picks the track and keeps the other two for the cases where they win.

Decision What moves when the user goes to the next slide?

Native scrolling with CSS scroll snap

The viewport is overflow-x: auto with scroll-snap-type: x mandatory; each slide has scroll-snap-align: start. scroll-padding-inline on the viewport moves the snap point when slides should not start at the very edge, for example to leave room for a peek.

Buttons call scrollTo({ left, behavior: "smooth" }). An IntersectionObserver reports which slide is mostly visible, and the scrollend event, in every major engine since Safari 26.2 in late 2025, says when the scroll has stopped.

Pros

  • The browser does the swipe, momentum, and snapping, on the compositor thread. Swipes stay smooth even while the main thread is busy, for example during hydration.
  • Works before any JavaScript loads. Trackpads, wheels, and keyboard scrolling just work.
  • Very little JavaScript.

Cons

  • Little control: the duration and easing of a smooth scroll are up to the browser, and a fast fling can skip two slides unless you add scroll-snap-stop: always.
  • Looping needs clones and a hidden jump of scrollLeft, which can flicker if it lands mid-momentum.
  • No mouse drag on desktop without extra code, and the current index arrives late, after scrolling, which makes controlled mode awkward.

A track moved with transform

The viewport clips; inside it, a flex row of slides (the track) moves with transform: translate3d(x, 0, 0).

Pointer events drive x during a drag; on release, a CSS transition animates to the snapped position.

Pros

  • Full control: easing, duration, edge resistance, looping, paging by 3 slides, and the same feel in every browser.
  • Controlled mode is natural: the index is known before the motion starts.
  • transform changes only composite: no layout, no paint, if the track is on its own layer.

Cons

  • We own the gesture code: thresholds, velocity, the vertical scroll conflict. Get it wrong and it feels worse than native.
  • Before hydration there's no swiping.
  • Focus can scroll a clipped viewport on its own and misalign the track unless we prevent it.

One slide at a time

Only the current slide is in the DOM. Changing the index swaps it, with a crossfade or no animation.

Pros

  • Simplest and cheapest. Nothing off screen exists.
  • A fade suits a hero banner and is gentle for people sensitive to motion.

Cons

  • Nothing can follow the finger: there's no next slide to drag into view, so swipes feel dead.
  • Can't show several slides per view.

What we pick: A transform track as the engine of the design system component. It's the only option that gives all teams loop, controlled index, paging, autoplay, and identical motion, and with compositor-only transforms it meets the frame budget. We keep scroll snap in mind as the right answer for a team that only needs a simple non-looping row, and we use the one-at-a-time fade as a mode for heroes when prefers-reduced-motion is set. Saying when you'd pick the losing option is part of a strong answer.

How the track works

Three elements. The viewport is the visible window: it has a fixed aspect ratio and clips everything outside it. The track is a flex row holding every mounted slide.

Each slide has flex: 0 0 calc(100% / n), where n is slides per view, so slides size themselves from the viewport width with no JavaScript measuring. The position of the track is one number:

ts
// width = viewport width / slidesPerView, measured once per resize
// dragPx is already flipped for RTL, so a negative value always means "toward the next slide"
function trackX(index: number, width: number, dragPx: number, rtl: boolean) {
  const x = -index * width + dragPx;
  return rtl ? -x : x;
}

// While dragging: no transition, write the transform directly.
track.style.transition = "none";
track.style.transform = `translate3d(${trackX(i, w, dx, rtl)}px, 0, 0)`;

// On release: animate to the snapped slide, then report it.
track.style.transition = "transform 320ms cubic-bezier(0.22, 1, 0.36, 1)";
track.style.transform = `translate3d(${trackX(next, w, 0, rtl)}px, 0, 0)`;
The only number that moves: the track's offset in pixels

Two details from this code matter in an interview. First, during the drag we write style.transform directly in the pointer handler instead of calling setState. React re-rendering the carousel 120 times a second is exactly the per-frame work we promised to avoid. React state changes only at the start and end of a gesture.

Second, onSettle comes from the transitionend event, but that event never fires if the transform didn't change or the transition was interrupted, so we also set a timer for the duration plus 50 ms and settle on whichever comes first.

The viewport uses overflow: clip, not overflow: hidden. The difference is subtle and real: an element with overflow: hidden is still a scroll container, so when focus moves to a link in slide 4, the browser scrolls the viewport to show it, and now the track is off by a slide while our index still says 1. overflow: clip makes the element not scrollable at all. We also make off-screen slides inert, which the accessibility chapter explains.

Check your understanding · Exercise 3

On the transform track, a keyboard user tabs to a "Shop now" link in slide 2 while slide 1 is showing, and the slides end up half a slide out of line. Which changes fix it? Select all that apply.

The render window: which slides exist at all

Take the listing page with 100 photos, where the naive track mounts all 100 slides, each with an img. Count the cost on a phone 390 CSS pixels wide with a device pixel ratio of 3. Each photo is drawn 1170 pixels wide and, at 4 by 5, 1462 pixels tall. Decoded, that's 1170 x 1462 x 4 bytes, about 6.8 MB per image.

If the browser decodes even half of them, that's over 300 MB for one component. iOS Safari may reload a tab that uses too much memory, and the user loses their place. Even if the browser is clever about discarding decoded pixels, 100 image requests compete with the first one and push LCP back.

The fix is the same idea as a virtualized list: only the slides near the current one exist. We call that set the render window. The question is how to keep the track's math simple when most slides aren't there.

Decision How do we keep only nearby slides mounted?

Mount everything

Render all slides; rely on loading="lazy" to delay images.

Pros

  • Simplest. Every slide is in the DOM for in-page search and for crawlers.

Cons

  • Memory and requests grow with the number of slides.
  • Lazy loading inside a horizontal scroller is unreliable, as the next chapter shows.
  • Every hidden link and button needs inert anyway.

Empty placeholder slides outside the window

The track still has one box per slide, with the same width. Slides inside the window render their content; the rest render an empty div.

Pros

  • Track math is unchanged: slide i is always at -i * width.
  • 100 empty boxes cost almost nothing: no images, no listeners.
  • Works with slides per view and with looping.

Cons

  • Still 100 DOM nodes. For 10,000 slides you'd want full virtualization, but carousels never get there.

Full virtualization

Only the window's slides exist, each absolutely positioned at left: i * width inside a track sized for all of them.

Pros

  • Constant DOM size for any count.

Cons

  • More code: absolute positioning, recycling keys, and care with flex-based slides per view.
  • Solves a problem a carousel doesn't have.

What we pick: Empty placeholder slides. It keeps the one-line position formula, handles every count a carousel realistically sees, and the cost of 100 empty boxes is a rounding error. We'd only move to full virtualization for something like a 10,000-frame film strip, which is a different component.

How big is the window

The window is the visible slides plus a margin on each side. With one slide per view and a margin of 1, that's 3 slides: previous, current, next. The margin is what lets a swipe reveal the next slide already painted, instead of a blank box that fills in mid-gesture.

A margin of 2 in the direction of travel helps people who swipe quickly through a gallery; most people move forward, so we use 1 behind and 2 ahead once the user has swiped at least once. With slides per view n, the window is n visible slides plus the margins, so 4 per view means about 7 mounted.

Slides that leave the window unmount. Coming back to them is cheap: the image is in the HTTP cache because our CDN sends Cache-Control: public, max-age=31536000, immutable for versioned URLs, so the only cost is decoding again, a few milliseconds. If a slide holds state the user cares about, like a playing video, the slide can ask to stay mounted with a keepMounted flag, but that's the exception.

SlidesMountAhead
Slide 1 of 20 with a window of 1 behind and 1 ahead. 2 images are mounted, 2 have been downloaded so far, and decoded memory is about 14 MB in the worst case.slides 1 to 8 around the current one12345678mounted now: 2 of 20downloaded so far: 2decoded, worst case: 14 MBgreen: in the windowred: mounted, not neededfaded: empty box
Slide 1 of 20 with a window of 1 behind and 1 ahead: two images mounted. Press Next.

Preloading the neighbors without downloading twice

The obvious way to preload the next image is new Image().src = nextUrl. It has a trap.

The real slide uses srcset and sizes, so the browser chooses among 5 widths based on the screen. The preload code picks one URL itself. If it picks 720w and the browser later picks 1280w for the real img, the user downloads the photo twice, and the preload was waste.

Mounting the neighbor slide with its real img avoids that: the same srcset and sizes produce the same choice, so the visible image is a cache hit. That's another reason the render window is the preloading mechanism, not a separate system. If you do need to preload without mounting, for example the first slide of the next feed post, use link rel="preload" as="image" with imagesrcset and imagesizes, which make the browser choose the same way.

Carousels that aren't on screen yet

The feed has 30 posts with carousels. A post far below the screen shouldn't mount its neighbors at all. One shared IntersectionObserver with a rootMargin of one screen height watches every carousel: until a carousel is within a screen of the viewport, its window is only slide 0, and slide 0 itself uses loading="lazy", which works well for vertical distance.

Once near, the window grows to include slide 1. Scrolling through all 30 posts then downloads about 60 photos, a first photo and one neighbor per post, instead of 300. Posts the user never reaches download nothing.

Check your understanding · Exercise 4

A gallery of 40 photos uses a render window of 1 behind and 2 ahead. The user is on photo 10. Which statements are true? Select all that apply.

Loading images: fast first slide, zero layout shift, nothing wasted

On the product page and the home page, the first slide is the LCP element. Whatever the carousel does to that one image decides a Core Web Vital that affects search ranking and sales. Everything else in the carousel should stay out of its way. This chapter goes through the attributes on each img and why each one is there.

The right width: srcset and sizes

A photo shown 390 CSS pixels wide on a phone with a pixel ratio of 3 needs about 1170 real pixels. The same slot on a 1440-pixel desktop, where the gallery takes 600 CSS pixels at ratio 2, needs about 1200. A phone on a smaller slot might need only 640.

srcset lists the widths the CDN can make, and sizes tells the browser how wide the slot will be, before any CSS loads. The browser multiplies the slot width by the pixel ratio and usually picks the smallest file that's big enough. It may also reuse a larger file it already has in its cache.

html
<img
  src="/img/p/8841-1.jpg?w=960"
  srcset="/img/p/8841-1.jpg?w=480 480w, /img/p/8841-1.jpg?w=720 720w,
          /img/p/8841-1.jpg?w=960 960w, /img/p/8841-1.jpg?w=1280 1280w,
          /img/p/8841-1.jpg?w=1600 1600w"
  sizes="(min-width: 1024px) 600px, 100vw"
  width="1600" height="2000"
  alt="Blue linen shirt, front view"
  fetchpriority="high"
  loading="eager"
  decoding="auto"
/>
The first slide of the product gallery, as the server renders it

The carousel knows the slides per view, so our CarouselImage helper writes sizes for the team. With { "0px": 2, "768px": 4 } inside a 1200-pixel container, it produces (min-width: 1200px) 300px, (min-width: 768px) 25vw, 50vw. Getting sizes wrong is the most common image bug: the default is 100vw, so a card that's a quarter of the screen downloads an image 4 times too wide. The CDN picks AVIF or WebP from the Accept header and sends Vary: Accept so caches keep the formats apart.

Why loading="lazy" is the wrong tool inside the row

Native lazy loading delays an image until it's within some distance of the viewport. Inside a horizontal row, browsers disagree about that distance. Since Chrome 121, Chrome uses the same thresholds sideways as vertically, about 1250 pixels on a fast connection, so a scrolling row on a phone may start loading the next three or four slides at once. Safari and Firefox have loaded such images only when they become visible, so the user sees a blank box slide in.

One behavior wastes bytes, the other shows blank slides, and both have changed between versions. That's the real problem: we can't design around behavior we don't control. On our transform track it's worse: hidden slides sit in a clipped box that isn't a scroll container, so lazy images there are likely to wait until they're on screen.

So the carousel decides loading itself, through the render window. Inside the window, images load eagerly; outside it, they don't exist. Lazy loading still has one good job: slide 0 of a carousel far down the page, where the distance is vertical and the browser's rule works well. And it must never be on the first slide of an above-the-fold carousel: a lazy LCP image waits for layout before it even starts downloading, which typically costs hundreds of milliseconds.

Priority and decoding

  • fetchpriority="high" on the first slide only. Browsers start images at low priority and raise the ones that turn out to be in the viewport after layout. The hint lets the first slide start at high priority straight from the HTML. Putting it on every slide cancels it out: if everything is high, nothing is.
  • The first slide must be in the server HTML as a real img. If the carousel renders only on the client, or the hero is a CSS background-image, the browser's preload scanner can't see the URL, and the download waits for JavaScript or CSS.
  • decoding="async" on the neighbors. It tells the browser it may decode the image without holding up other content. For the first slide we leave the default, so it shows as soon as it's ready.
  • img.decode() before a long jump. When a dot click jumps from slide 1 to slide 7, slide 7 mounts at that moment. We call decode() on its image and start the animation when the promise resolves, waiting at most 100 ms. Otherwise the decode can land in the middle of the animation and drop frames.
A loading waterfall for the product page on 4G, from 0 to 2.5 seconds. The HTML arrives by 0.35 seconds. Slide 1 starts at 0.4 seconds with high priority, straight from the HTML, and finishes at 1.2 seconds; LCP is about 1.3 seconds. CSS loads from 0.4 to 0.7 seconds and the carousel's JavaScript from 0.5 to 1.4 seconds. Slide 2 starts at 1.5 seconds, after hydration mounts the render window, at low priority. Slides 3 to 8 are not requested.LCP about 1.3 sHTMLslide 1, highCSScarousel JSslide 2, lowslides 3 to 8not requested until they enter the window0 s1 s2 s
Slide 1 doesn't wait for CSS or JavaScript: it's an img in the server HTML with fetchpriority="high". Slide 2 arrives after LCP, so it never competes with it, and slides 3 to 8 cost nothing yet.

Zero layout shift: reserve the box

If the carousel's height comes from its images, the page below jumps when the first image loads. That's layout shift, and it's also why people mis-tap the button under a carousel. The viewport gets aspect-ratio: 4 / 5 from the aspectRatio prop, so its height is known from its width the moment CSS applies.

Each img also carries width and height attributes, which modern browsers turn into an aspect ratio too, and object-fit: cover or contain decides how a photo with a different shape fills the box. The feed uses the first photo's ratio for the whole post and letterboxes the rest with contain; the product page uses one ratio for all photos because the studio shoots them that way.

Decision What should the reserved box show while the image loads?

A dominant color

The API returns one color per photo, like #c9b8a6; the slide paints it as background-color.

Pros

  • 7 bytes per photo. No JavaScript. Already looks like the photo from a distance.

Cons

  • Flat. On a slow network the user stares at a beige box.

An inline low-quality image (LQIP)

A 20-pixel-wide version of the photo, about 300 to 600 bytes, inlined as a data URL and stretched with filter: blur(16px). The real image fades in over it (blur-up).

Pros

  • Shows the shape and colors right away. No JavaScript needed.
  • Chrome doesn't count very blurry, low-detail images as the LCP element, so it doesn't fake the metric.

Cons

  • Adds bytes to the HTML for every slide that uses it, so only worth it for a few.
  • The fade needs care with reduced motion.

A hash such as BlurHash or ThumbHash

A string of 20 to 30 characters from the API, decoded into a tiny image by JavaScript.

Pros

  • Smaller than LQIP, and nice-looking.

Cons

  • Needs a decoder in JavaScript, and the placeholder can't appear until that code has run, which is the wrong place to add work.
  • Doesn't render without JavaScript.

What we pick: A dominant color for every slide, plus an inline LQIP only for the first slide of the hero and the product gallery, where people actually wait on it. A hash is a fine choice for an app that already ships the decoder, but adding JavaScript in front of the LCP image works against the requirement we care about most.

Check your understanding · Exercise 5

The home page hero has 5 slides and LCP is 3.4 seconds. Which changes are likely to help? Select all that apply.

Input: swipes, drags, buttons, and the keyboard

With a transform track, we own the gesture. This is where carousels most often feel broken: the photo won't move, or the page won't scroll, or a link opens at the end of a swipe. All three come from one hard question: when a finger lands on a slide and starts moving, does the user want the carousel or the page?

Decision How do we tell a horizontal swipe from a vertical page scroll?

Touch events and preventDefault

Listen to touchmove with { passive: false }. Look at the first few pixels; if horizontal, call preventDefault() to stop the page scrolling and move the track.

Pros

  • Full control of the decision in JavaScript.

Cons

  • A non-passive touch listener makes the browser wait for our JavaScript before it can scroll at all. If the main thread is busy, the whole page scroll stutters, not just the carousel.
  • Touch only. Mouse and pen need a second code path.

Pointer events with touch-action: pan-y

The viewport has touch-action: pan-y pinch-zoom. The browser handles vertical pans and pinch zoom itself; horizontal movement is left to the page.

We listen to pointerdown, pointermove, pointerup, and pointercancel.

Pros

  • The browser makes the scroll decision on its compositor thread, without waiting for JavaScript. Vertical scrolling stays smooth even on a busy page.
  • One code path for touch, mouse, and pen.
  • Pinch zoom keeps working, which people with low vision rely on.

Cons

  • When the browser takes over a vertical pan it fires pointercancel, and we must handle that by snapping back.
  • Mouse drags have no touch-action, so we add our own small direction check.

Let native scrolling do it

Use the scroll snap layout instead; the browser handles both axes.

Pros

  • Zero gesture code.

Cons

  • Brings back every limit of the scroll snap layout we already rejected: looping, timing control, controlled mode.

What we pick: Pointer events with touch-action: pan-y pinch-zoom. The browser is better placed than our code to decide the direction of a touch, and it decides without blocking scrolling. We only handle the horizontal case, plus pointercancel when the browser claims the gesture.

The states of a pointer gesture. From idle, pointerdown goes to pending. From pending, 8 pixels of sideways movement goes to dragging, where the track follows the finger; a vertical pan goes to page scrolls, where the browser takes over and sends pointercancel. From dragging, pointerup goes to settling; from page scrolls, the track snaps back through settling. Settling runs a CSS transition and returns to idle on transitionend or a timer.idletimer may runpendingunder 8 pxdraggingmoves each framesettlingCSS transitionpage scrollspointercancelpointerdown8 px sidewayspointerupverticalsnap backtransitionend or timer
The browser, not our code, decides that a pan is vertical, thanks to touch-action: pan-y. Our job is the sideways path, and handling pointercancel when the browser claims the gesture.

The drag, step by step

  1. pointerdown: remember the start point and time, stop autoplay, cancel any running transition by reading the track's current position, and call setPointerCapture so moves keep coming even if the pointer leaves the carousel.
  2. pointermove, pending: wait until the pointer has moved 8 pixels. This slop stops a tap with a slightly shaky finger from becoming a drag. For a mouse, if the vertical movement is larger than the horizontal, give up and let the page have it.
  3. pointermove, dragging: set the track's transform to the start position plus the horizontal distance, at most once per frame. At the first or last slide of a carousel that doesn't loop, divide the distance by 3, so the track resists like a rubber band and the user feels the end.
  4. pointerup: decide which slide to settle on, then animate there with a CSS transition. pointercancel: the browser took the gesture for scrolling or zooming, so animate back to the current slide.
  5. After a drag: swallow the click that follows pointerup, with a listener in the capture phase, so a swipe across a "Shop now" link doesn't open it. Also stop native image dragging on desktop with draggable="false".

Distance or speed: when does a swipe count

A slow, deliberate drag should change slides only if it went far enough: we use 25% of the slide width. But a quick flick of 40 pixels on a 390-pixel phone is clearly "next", even though it's only 10% of the width. So there are two rules, and either one is enough: distance past 25% of the width, or a release speed above about 0.3 pixels per millisecond in the same direction.

Speed is measured over the last 100 ms of movement, not from the start: a user who dragged slowly and then flicked meant the flick. For the same reason, a fast move back the other way at release cancels everything. A drag of 200 pixels that ends with a flick back is a change of mind, and it snaps back.

With several slides per view, the speed decides how far to go, not only which way. Project where the track would coast: the release speed times about 200 ms, added to the distance, rounded to the nearest whole slide and capped at one page. The lab below lets you try distances and speeds and see which rule decides.

Slide width
On slide 3, a drag of -40 pixels released at -0.6 pixels per millisecond, on a 390-pixel slide. The distance rule needs 98 pixels; a flick needs 0.3. Result: go to slide 4, because of the rule: flick in the drag's direction.slide 2slide 3slide 4-98+98fingerdistance: 40 of 98 pxspeed: 0.6 of 0.3 px/msgo to slide 4flick in the drag's direction
Either rule is enough: past 25% of the width, or a flick faster than 0.3 px/ms in the same direction. A fast move back the other way cancels. Turn on right to left and the same drag means the opposite.

Buttons and the keyboard

Previous and next are real button elements with labels such as "Previous slide" and "Next slide", not divs with click handlers, so they work with Enter, Space, and every assistive technology for free. They're always in the DOM, not only on hover, because touch and keyboard users have no hover. Each is at least 24 by 24 CSS pixels to meet the WCAG 2.2 target size rule; we use 44.

At the ends of a non-looping carousel, don't set the native disabled attribute on the button that has focus. A disabled button can't hold focus, so a keyboard user who presses "Next" on the second-to-last slide suddenly has focus on the body, and a screen reader user loses their place. Use aria-disabled="true" instead, which announces the state, keeps focus where it is, and lets our click handler ignore the press.

The dots or thumbnails follow the APG tabbed carousel: a tablist where Tab enters once and the Left and Right arrow keys move between dots. That keeps a row of 12 thumbnails from becoming 12 Tab stops.

Check your understanding · Exercise 6

Users on phones say the page "gets stuck" when they try to scroll down past the carousel. Which causes fit? Select all that apply.

Infinite looping without glitches

The hero loops: after slide 5 comes slide 1, in the same direction. The naive version computes the next index with modulo, (index + 1) % count, and moves the track there.

On the last slide that means the index goes from 4 to 0, and the track animates from -4 * width back to 0. The user sees all five slides rush past backwards. That's a rewind, not a loop. To keep moving forward, slide 1 has to appear to the right of slide 5.

Decision How do we make slide 1 appear after slide 5?

Rewind on purpose

Don't loop. At the end, "next" animates quickly back to the first slide, or is disabled.

Pros

  • No edge cases. Users always know where the end is, which suits galleries.

Cons

  • Not what the hero asked for. The backward rush is jarring during autoplay.

Clone boxes at both ends, then jump

Add k extra boxes before the first slide and after the last, where k is slides per view plus the window margin. Box positions run from -k to count + k - 1; each box shows items[mod(position, count)].

When the track settles on a clone, swap it in the same frame, with no transition, for the real slide that looks identical.

Pros

  • Fits our track: clones are just more placeholder boxes, and the render window mounts only the near ones.
  • Every move is a normal forward or backward step.

Cons

  • The jump must be invisible, which needs care (below).
  • Clones must not be read by screen readers or receive focus, and a stateful slide such as a playing video loses its state when we jump.

Move real slides to the other side

Keep an unbounded virtual index. Position each mounted slide at its virtual position with its own transform, so item 0 can sit after item 4 without a copy. Some libraries, such as Embla, work this way.

Pros

  • No clones and no jump. The track can keep going forever.

Cons

  • Every slide gets its own transform, which is the full virtualization layout we chose not to build.
  • Track offsets grow without limit and need resetting while idle.

What we pick: Clones and a jump, because our placeholder track and render window make clones nearly free and keep one layout for every mode. If we'd built the track with individually positioned slides, the virtual index would be the better choice. Both are defensible; what matters in the interview is naming the edge cases of the one you pick.

A looping carousel of 5 slides with two clone boxes at each end. Positions run from minus 2 to 6. Positions minus 2 and minus 1 show clones of slides 4 and 5; positions 0 to 4 are the real slides 1 to 5; positions 5 and 6 show clones of slides 1 and 2. From slide 5 at position 4, next animates to position 5, a clone of slide 1. Then the track jumps, with no transition, to position 0, the real slide 1.4'-25'-110213243541'52'61. animate2. jump with no transitionhatched: clones, inert, labels use the real number
The user sees one step forward. The jump happens in a single frame between two identical pictures, so it's invisible, as long as the real slide 1 is already mounted and decoded.

Making the jump invisible

Say there are 5 slides and k is 2, so positions run from -2 to 6. The user presses next on slide 5 (position 4). The track animates to position 5, which shows item 0.

On settle, we set transition: none, move the track to position 0, set the index to 0, and on the next frame turn the transition back on. The pixels before and after are identical, so nobody sees it.

Except when they aren't identical. Position 0 was far from position 5, so the real slide 1 wasn't in the render window. After the jump it mounts, and its image is only a placeholder for a frame or two: a visible flash.

The fix is to compute the window with modulo, so near a loop boundary it includes the real counterparts of any mounted clones. Then both copies are mounted and decoded before the jump happens.

Edge cases to name

CaseWhat goes wrongFix
Next pressed 4 times in 300 msThe track runs past the last clone into empty space before any settle runs.On each press, if a transition is running, finish it at once (jump to its target and normalize), then start the new step. Or queue presses and run them one by one.
Finger lands during the jump frameThe drag starts from the clone's position while the index already says 0.Read the track's real position at pointerdown and normalize first, before computing anything.
Dot 1 clicked while on slide 5Going "to index 0" rewinds through every slide.Pick the shortest way: from 4 to 0 is one step forward through the clone. Ties go forward.
Only 2 slidesWith a margin of 1 on each side, the same item is mounted twice at once.Key slides by position, not by item key, so React doesn't confuse them. It's fine for one item to show twice.
Fewer slides than slides per viewThere's nothing to loop; clones show the same cards side by side.Turn looping off when count is at most slides per view, and hide the controls.
Screen reader on a cloneIt announces "slide 6 of 5", or reads duplicates.Clones are inert; labels always use mod(position, count) + 1.

Check your understanding · Exercise 7

A looping hero with 4 slides uses clones. On the last slide, the user presses next and briefly sees a grey box before the first image appears. What is the most likely cause, and fix? Select all that apply.

Autoplay done right

Start with the honest question: should the hero rotate at all? Usability research, such as the Nielsen Norman Group's articles on carousels, keeps finding that people ignore auto-rotating banners or get annoyed when a slide moves while they're reading it. A slide that changes under the cursor also causes mis-clicks. Say this in the interview, then build it well anyway, because marketing teams will ask for it and a design system should make the safe version the easy one.

The naive version is setInterval(next, 5000) started on mount. It rotates while the user reads, while they hover to click, while their keyboard focus sits on a link inside a slide, while the tab is in the background, and for people who've asked their operating system for less motion. It also fails WCAG 2.2 success criterion 2.2.2, Pause, Stop, Hide, a level A rule: content that moves on its own for more than 5 seconds next to other content must have a way to pause or stop it.

Decision How should autoplay decide whether it's running?

One paused boolean

Hover sets it to true, leaving sets it to false, the pause button toggles it.

Pros

  • Tiny.

Cons

  • Pauses overwrite each other. The user hovers (paused), switches tabs, comes back (visibility says "resume"): it rotates under their cursor.
  • Can't tell a user's deliberate stop from a passing hover.

A set of temporary pause reasons plus a user stop

pausedBy holds reasons that end on their own: hover, drag, hidden, offscreen. stopped is set by the user: the pause button, focus entering, or any manual navigation.

It runs only when stopped is false, pausedBy is empty, and reduced motion is off.

Pros

  • Each source adds and removes only its own reason, so they can't overwrite each other.
  • A deliberate stop stays stopped until the user presses play.

Cons

  • A few more lines of state, all of them easy to test.

What we pick: A set of temporary reasons plus a user stop. The bug in the boolean version isn't rare: hover plus a tab switch happens all the time on desktop. The set makes every rule independent and every combination correct.

The rules, one by one

  • Off by default, opt in per use. Galleries and feed posts never rotate.
  • Respect prefers-reduced-motion: reduce. Read it with matchMedia and listen for its change event. When it's set, don't start autoplay, and change slides with an instant swap or a short fade instead of a slide.
  • A visible pause button, first in the carousel's tab order. The APG pattern puts the rotation control before the slides, so a keyboard user can stop rotation before reaching anything that moves. Its label changes between "Stop slide rotation" and "Start slide rotation".
  • Focus entering the carousel stops rotation for good. The APG pattern says it doesn't restart unless the user asks. A keyboard user can't read a slide that changes under their focus.
  • Hover pauses; leaving resumes. The mouse resting on a slide usually means reading or about to click.
  • A hidden tab pauses. Listen to visibilitychange and check document.visibilityState. Browsers throttle timers in background tabs anyway, but without this rule the slide can change the moment the user returns, which is exactly when they look at it.
  • Off screen pauses. An IntersectionObserver pauses rotation when the hero is scrolled out of view, which also saves CPU and battery.
  • A drag pauses, and any manual navigation stops. Pressing next, clicking a dot, or swiping means the user is in control now.
  • Long enough to read. At least 5 seconds per slide, more if a slide has more than a short headline. We default to 6.
ts
type PauseReason = "hover" | "drag" | "hidden" | "offscreen";

function shouldRun(s: { stopped: boolean; pausedBy: Set<PauseReason>; reducedMotion: boolean }) {
  return !s.stopped && s.pausedBy.size === 0 && !s.reducedMotion;
}

// Called after every settle and whenever pausedBy, stopped, or reducedMotion change.
function schedule(state: AutoplayState, intervalMs: number, next: () => void) {
  clearTimeout(state.timer);
  if (!shouldRun(state)) return;
  state.timer = setTimeout(() => next(), intervalMs); // full interval, from settle
}

document.addEventListener("visibilitychange", () => {
  if (document.visibilityState === "hidden") pause("hidden");
  else resume("hidden");
});
One timeout per slide, restarted after every settle, never an interval

Why a timeout per slide and not an interval? The interval keeps ticking on its own schedule. If the user presses next at 4.9 seconds, the interval fires 0.1 seconds later and skips the slide they just chose. With one timeout started at each settle, every slide gets its full time, and resuming from a pause also restarts the full interval, which gives the user a moment after they move the mouse away.

What screen readers hear while it rotates

The slide container is a live region, so a screen reader announces the new slide when the user presses next. During autoplay that would be noise: an announcement every 6 seconds, interrupting whatever the user is reading elsewhere on the page. The APG pattern solves it with one attribute: aria-live="off" while rotating, aria-live="polite" when rotation is stopped. Try the combinations in the lab: add and remove pauses, stop and play, and watch the timer and the live region.

Pauses stored as
Temporary
Five slides; slide 1, Sale, is showing. 0 of 6 seconds have passed. Pauses are stored as a set of reasons. The timer is running. The live region is off. Last announcement: nothing yet.SaleReturnsJacketsGift cardsApp deal1s2s3s4s5s6stimer runninguser stop: falsepausedBy: emptyreduced motion: offaria-live: offlast announced:nothing yet
Autoplay is on. Press Wait 1 s to move the clock, then add pauses.

Check your understanding · Exercise 8

Autoplay is running on the hero. Which events should leave it stopped until the user presses the play button? Select all that apply.

Accessibility: the APG carousel pattern

A carousel built from divs with click handlers is invisible to many users in different ways. A screen reader hears "image, image, image" for every slide at once, with no idea that they're a set or which one is showing. A keyboard user tabs into links on slides they can't see.

Someone with a vestibular disorder gets motion they didn't ask for. The WAI-ARIA Authoring Practices Guide (APG) has a carousel pattern that addresses all of this, and following it is both the right thing and the strongest signal in an interview.

Roles and labels

  • The container is a section element with aria-roledescription="carousel" and aria-label="Product photos". A section with a name becomes a region, so screen reader users can jump to it as a landmark. The role description makes the reader say "carousel" instead of "region".
  • Each slide has role="group", aria-roledescription="slide", and aria-label="3 of 8". The user hears "3 of 8, slide", which answers the two questions every carousel raises: where am I, and how many are there. If slides have real titles, the label can be "3 of 8: Summer jackets". When the dots are a tablist (below), APG switches the slides to role="tabpanel" and drops their role description.
  • Previous and next are button elements labeled "Previous slide" and "Next slide", with aria-controls pointing to the slides' container.
  • The rotation button, when autoplay is on, comes first in the tab order and is labeled by what it does: "Stop slide rotation" or "Start slide rotation".
  • The slides' container has aria-live="polite" when not rotating and aria-live="off" while rotating, so manual changes are announced and automatic ones aren't.
  • Hidden slides get the inert attribute. It removes them from the tab order and from the accessibility tree in one step, with no need to hunt for every link and set tabindex="-1". It works in all current browsers.
html
<section aria-roledescription="carousel" aria-label="Spring offers">
  <button aria-label="Stop slide rotation">...</button>
  <button aria-label="Previous slide" aria-controls="hero-slides">...</button>
  <button aria-label="Next slide" aria-controls="hero-slides">...</button>
  <div id="hero-slides" aria-live="off">
    <div role="group" aria-roledescription="slide" aria-label="1 of 5" inert>...</div>
    <div role="group" aria-roledescription="slide" aria-label="2 of 5">
      <img src="..." alt="" />
      <h2>Linen for the warm days</h2>
      <a href="/linen">Shop linen</a>
    </div>
    <div role="group" aria-roledescription="slide" aria-label="3 of 5" inert>...</div>
  </div>
</section>
The basic pattern for a hero with autoplay, slide 2 of 5 showing, before the dots are added

Look at the hero image's alt="". The slide's message is in the heading and the link, which are real text, so the photo behind them is decorative and an empty alt keeps the screen reader from describing it. On the product gallery it's the opposite: the photo is the content, so each one needs a real description such as "Blue linen shirt, back view, showing the pleat".

Text baked into a banner image is a common failure: screen readers can't read it, translators can't translate it, and it blurs on a big screen. Keep text in HTML on top of the image, and check its contrast against the lightest part of the photo, using a scrim (a dark gradient) if needed.

The accessible structure of a hero carousel. A section with aria-roledescription carousel holds, in tab order: a Stop rotation button, Previous and Next buttons, and the slides container with aria-live off. Inside it are three tab panels for slides 1, 2, and 3 of 5; the first and third are inert and the second is visible. Because the dots are tabs, the slides use the tabpanel role instead of group. Below, a tablist holds five tabs with tab 2 selected, one Tab stop for all of them.section: carousel, Spring offersStop rotationPreviousNextslides: aria-live offtabpanel: 1 of 5inerttabpanel: 2 of 5visibletabpanel: 3 of 5inerttablisttab 1tab 2tab 3tab 4tab 5
The rotation button comes first, so a keyboard user can stop the motion before reaching anything that moves. Hidden slides are inert, and the live region turns polite only when rotation stops.

Decision How should the dots or thumbnails work for keyboard and screen reader users?

No picker, only previous and next

Hide dots from assistive technology with aria-hidden and make them non-focusable; the slide labels say "3 of 8".

Pros

  • Smallest tab order: three controls at most.

Cons

  • Jumping from slide 1 to slide 7 takes 6 presses.

A tablist (APG tabbed carousel)

Dots are role="tab" in a role="tablist" labeled "Choose slide to display"; slides become role="tabpanel" without aria-roledescription. One tab stop, arrow keys move between dots, aria-selected marks the current one.

Pros

  • One Tab stop for any number of dots.
  • Screen readers say "tab 3 of 8, selected".

Cons

  • Users expect tab behavior: arrow keys, and the panel following the selection. Half-implemented tabs are worse than buttons.

A group of buttons (APG grouped carousel)

The dots sit in a group labeled "Choose slide to display". Each dot is a button labeled "Slide 3", with aria-disabled="true" on the current one.

Pros

  • Simple, familiar button behavior.

Cons

  • Every dot is a Tab stop. Ten dots are ten presses to get past the carousel.

What we pick: A tablist for the hero and the product gallery's thumbnails, because it stays one Tab stop. For the feed's 20-photo posts, no dots at all: 20 dots would each be smaller than the 24-pixel target size and say nothing a "3 / 20" counter doesn't. Previous and next plus the counter are enough there.

Check your understanding · Exercise 9

A screen reader user reports: "I hear all eight photos read out in a row, and I can't tell which one is on screen." Which fixes address that? Select all that apply.

Responsive layout and right-to-left languages

The related products row shows 2 cards on a phone and 4 on a tablet or a desktop. The same component also sits in a 320-pixel sidebar on the order confirmation page. That last case is the clue: what matters is how wide the carousel is, not how wide the screen is.

Decision Where do the slides-per-view breakpoints come from?

Media queries on the viewport

@media (min-width: 768px) sets slides per view to 4.

Pros

  • Familiar, and correct in the server HTML.

Cons

  • Wrong in the sidebar: the screen is 1440 pixels wide, so it asks for 4 cards in a 320-pixel column.

JavaScript only, with ResizeObserver

Measure the viewport element and pick the count from the config.

Pros

  • Based on the carousel's own width. One source of truth for the math.

Cons

  • Nothing is known until JavaScript runs. The server renders a guess, and the row jumps from 1 card to 4 after hydration: layout shift.

Container queries for CSS, the same config for JavaScript

The viewport has container-type: inline-size. The server turns the breakpoint config into @container rules that set the custom property (for example --per-view: 4;); slides are calc(100% / var(--per-view)) wide.

JavaScript reads the same config with a ResizeObserver for its index math.

Pros

  • Correct before any JavaScript, in every placement.
  • CSS and JavaScript agree because both come from one config object.

Cons

  • Two consumers of the config to keep in sync; a unit test checks they produce the same count for sample widths.

What we pick: Container queries for layout and the same config in JavaScript. It's the only option that's right both in the server HTML, so there's no layout shift, and in a narrow sidebar on a wide screen. Container queries are supported in all current browsers.

Slides per view from one config, 2 below 768 pixels and 4 from 768 pixels, applied to the carousel's own width. A 390-pixel container on a phone shows 2 cards. An 820-pixel container on a tablet shows 4. A 320-pixel container in a sidebar on a 1440-pixel screen shows 2, where a media query on the screen would have wrongly asked for 4.phone, 390 px: 2 per viewtablet, 820 px: 4sidebar, 320 px on a 1440 screen: 2, not 4slidesPerView={{ "0px": 2, "768px": 4 }}
Container queries size the slides from the carousel's width, before any JavaScript runs. The same config feeds the index math through a ResizeObserver.

Paging, peeking, and what happens on resize

  • Next moves a page, not a slide. With 4 per view, next moves by 4, so the user sees 4 new cards. Dots stand for pages: 10 products at 4 per view is 3 dots, and the last page is aligned to the end so it shows products 7 to 10, not 9 and 10 with empty space.
  • Peek to hint at more. A slide width of calc(100% / (var(--per-view) + 0.15)) shows 15% of the next card. On phones that peek is the best affordance for "you can swipe this", better than tiny dots.
  • Gaps belong in the math. With gap: 12px, a slide's step is its width plus 12, so the position is -index * (width + gap), and each slide is calc((100% - (n - 1) * 12px) / n) wide. Forgetting the gap makes the track drift by 12 pixels per slide.
  • Keep the first visible slide on resize. Turning a phone sideways can change 2 per view to 4. Keep the same first visible card, clamp so the last page isn't half empty, and jump without animation, since nobody asked for motion.

Right to left

In Arabic and Hebrew, reading goes right to left, so slide 2 sits to the left of slide 1, and "next" is the button on the left with an arrow pointing left. With dir="rtl" on the page, the flex track lays out right to left by itself. Three things don't flip by themselves, and each is a classic bug.

  1. The transform. Moving to slide 2 now means moving the track to the right, so the offset's sign flips. That's the rtl branch in the trackX function in the layout chapter.
  2. The swipe. Dragging the content to the right reveals the next slide, so the drag's sign flips before the threshold rules run.
  3. The keys and icons. In the dots' tablist, the Left arrow means next. The buttons use logical CSS, inset-inline-start and inset-inline-end instead of left and right, and the arrow icons are mirrored.

Read the direction from the carousel element with getComputedStyle(element).direction, not from document.dir. A page in English can embed an Arabic product description with its own dir, and the carousel inside it must follow its own block.

Check your understanding · Exercise 10

On the Arabic site, users report that swiping left goes to the previous photo and the counter disagrees with the photo shown. Which fixes are needed? Select all that apply.

Architecture: the parts and where state lives

We've made the big decisions one at a time. Now put them together into parts with clear jobs. The test of a good split is that each decision from the earlier chapters lives in exactly one part, so a bug report like "autoplay resumed after I pressed pause" points at one file.

Decision How should the carousel be packaged for teams?

One styled component

Carousel renders everything, with props for every variation.

Pros

  • Easy to start with. Every carousel on the site looks the same.

Cons

  • Every new design need becomes a prop. Within a year there are 40 props and the thumbnail strip is a fork.

A headless core plus a styled default

useCarousel holds the state, gestures, autoplay, window, and ARIA attributes, and returns prop getters such as getSlideProps(i). Carousel is our default look built on it.

Pros

  • Teams with special designs use the hook and keep all behavior, including accessibility.
  • Behavior is tested once, in the core, independent of styles.

Cons

  • Two layers to document. Prop getters are a pattern some developers haven't seen.

What we pick: A headless core plus a styled default. Carousels vary in look far more than in behavior, and the behavior is the expensive, easy-to-break part. Our own Carousel is the first customer of the hook, which keeps the hook complete.

The carousel's architecture. The page passes props into useCarousel, the headless core. Inside the core, the gesture controller and the autoplay controller send index requests to the index state, which drives the track, for the transform and settle, and the render window, which decides which slides mount. On the styled side, the Carousel's controls also send index requests; the render window feeds the slide wrapper, which holds the role, label, and inert, and the slide wrapper holds the CarouselImage helper with srcset and priority.Page: propsuseCarousel: headless coreGesturespointer eventsAutoplaypause reasonsIndex staterequestIndex()Tracktransform, settleRender windowwhich slides mountCarousel: styledControlsbuttons, tablistSlide wrapperrole, label, inertCarouselImagesrcset, priorityprops
Every way of changing slides, a swipe, a button, a key, or the timer, ends in one call: requestIndex(next, reason). That one door is why controlled mode and the change reason are always right.
PartResponsibilityWhy the boundary is here
Index stateHolds the current index, supports controlled and uncontrolled mode, and exposes requestIndex(next, reason).Every input goes through one door, so onIndexChange and the reason are always right.
Gesture controllerPointer events, slop, direction, rubber band, the release decision, click suppression.Runs outside React on every frame. Calls requestIndex once per gesture.
TrackWrites the transform, runs the transition, reports settle with the timer fallback, normalizes loop clones.The only code that touches the track's style, so motion bugs live in one place.
Render windowDecides which positions mount, using modulo near loop boundaries and the visibility of the whole carousel.Loading policy in one pure function that's easy to unit test.
Autoplay controllerPause reasons, user stop, reduced motion, one timeout per slide, the live region mode.Has nothing to do with layout; it only calls requestIndex with the reason autoplay.
Slide wrapperRole, role description, "3 of 8" label, inert, the box size.Teams render content; the wrapper guarantees the semantics.
ControlsDefault buttons, rotation button, dots as a tablist; or the team's own through renderControls.Built on the public CarouselApi only.
CarouselImagesrcset, sizes from slides per view, loading, priority, decoding, placeholder.Image policy in one helper, so teams get it right by default.

The states of a carousel

Most carousel bugs happen when two things overlap: a timer fires during a drag, a click arrives during a transition, a resize happens mid-swipe. Naming the motion states makes those overlaps explicit. There are three: idle, the track is still at a slide; dragging, a pointer owns the track; settling, a transition is moving the track to a slide. Autoplay is a separate, parallel machine that only acts when motion is idle.

Two state machines. Motion: idle goes to dragging on pointerdown plus 8 pixels, dragging goes to settling on pointerup, and settling returns to idle on transitionend. Autoplay: running goes to paused when a reason is added, and back to running when the last reason is gone; running goes to stopped on a user stop such as focus, a Next press, or the Stop button, and stopped returns to running only when Start is pressed. Autoplay only advances while motion is idle.motionidledraggingsettlingpointerdownpointeruptransitionendautoplaypausedhover, hiddenrunningstoppeduser stopreason addedlast reason goneuser stopStart pressed
Keeping the two machines apart makes overlaps easy to reason about: a timer that fires while motion isn't idle simply waits for the next settle.
  • Idle to dragging on pointerdown plus 8 pixels of horizontal movement. Autoplay gets the drag pause.
  • Settling to dragging when a finger lands mid-transition: read the track's real position with getComputedStyle and start the drag from there, so the user can catch a moving slide.
  • Settling, a button pressed: finish the transition at once and start the next one from the target. Never queue unbounded work.
  • Settling to idle on transitionend or the fallback timer. Fire onSettle, normalize loop clones, and let autoplay schedule its next timeout.

Data model: what lives where

A carousel has very little data, but putting each piece in the right place is what keeps it fast. Values that change on every frame live in refs and in the DOM, never in React state. Values that change once per slide live in React state, so the slide labels, dots, and live region render correctly.

ts
// Changes once per slide: React state (renders labels, dots, window)
type CarouselState = {
  index: number;           // a real index, 0..count-1; or a prop when controlled
  position: number;        // track position; differs from index on clones
  motion: "idle" | "dragging" | "settling";
  perView: number;         // from the config and ResizeObserver
  autoplay: { stopped: boolean; pausedBy: ReadonlySet<PauseReason> };
};

// Changes every frame: a ref, written straight to the DOM
type DragRef = {
  pointerId: number;
  startX: number;
  startY: number;
  startOffset: number;     // track offset when the drag began
  samples: { x: number; t: number }[]; // last 100 ms, for speed
};

// Comes from the page: props
type Item = { id: string; src: string; width: number; height: number; alt: string; color: string; lqip?: string };
The carousel's state, split by how often it changes

Nothing goes in the URL by default. The product page keeps the selected color in the URL, as ?color=red, and the gallery index follows from it through controlled mode. Putting the photo index itself in the URL with pushState would add a history entry on every swipe, so the back button would walk through photos instead of leaving the page. If a team wants shareable links to one photo, replaceState updates the URL without adding entries.

Performance and testing

The image chapter covered LCP and CLS. This chapter is about the other half of performance: how the carousel feels while someone uses it. Then we look at how to test all of it, because a carousel has more behavior per line of code than almost any other component.

Staying inside the frame budget during a swipe

The browser draws a frame in steps: JavaScript, style, layout, paint, composite. Changing transform or opacity on an element that has its own layer skips layout and paint and goes straight to composite, which the GPU does cheaply. Changing left or margin from JavaScript runs layout every frame. On a slow phone that can be the difference between a few milliseconds and most of the frame budget, so only transform moves the track, and the only other animated property is opacity for the reduced motion fade.

  • No layout reads in pointermove. Calling getBoundingClientRect() or offsetWidth after a style write forces the browser to run layout right then. Measure the slide width once, in a ResizeObserver callback, and keep it in a ref.
  • No React renders per frame. The drag writes the transform directly; React state changes at the start and end of a gesture only.
  • At most one write per frame. Pointer events can arrive faster than frames on 120 Hz touch screens and with pens. Keep the latest position and write it in a requestAnimationFrame callback.
  • Small work on release. Taps on the arrows and dots are interactions that INP measures, and after a swipe, the work on pointerup delays the first frame of the settle animation. Calling onIndexChange and rendering the new window must finish in a few milliseconds. Mounting the next neighbor's image can wait until settle.

A subtle point about INP: it measures discrete interactions, such as a tap, a click, or a key press, from input to the next paint. The frames in the middle of a drag aren't part of it. So a janky swipe can have a perfect INP.

To see jank during swipes, look at frames, not INP. Record a swipe in the DevTools Performance panel with 4x CPU slowdown. In the field, in Chromium browsers, collect long animation frames with the Long Animation Frames API (PerformanceObserver with type long-animation-frame) while a drag is active.

will-change: useful in one place, harmful in many

will-change: transform asks the browser to put an element on its own compositor layer ahead of time, so the first frame of a drag doesn't pay for creating it. That's worth it on the track while it's moving. It has costs that make "put it everywhere" a bug:

  • Memory. Each layer is a GPU texture. A layer the size of a full-width photo on a 3x phone is about 7 MB. On every slide of 30 feed carousels, that's hundreds of megabytes.
  • A new containing block. An element with will-change: transform becomes the containing block for position: fixed children. A team's "fixed" tooltip inside a slide suddenly scrolls with the slide.
  • A stacking context. z-index inside the track no longer competes with the rest of the page, which can hide a dropdown that opens from a slide.

So: will-change: transform on the track only, added at pointerdown or when a transition starts, and removed when the track settles. Idle carousels in the feed have no extra layers at all.

Check your understanding · Exercise 11

Swiping the product gallery stutters on a mid-range Android phone, but INP in the field is a healthy 120 ms. Which explanations and fixes are right? Select all that apply.

Testing, layer by layer

The headless split pays off here: most of the logic is pure functions that take numbers and return numbers, which are fast and easy to unit test. The browser-dependent parts are fewer and get slower, more realistic tests.

LayerWhat it checksTools
UnitRelease decision (distance, speed, change of mind), render window with modulo, loop normalization, shortest path for dots, breakpoints config, shouldRun for autoplay.Vitest; plain inputs and outputs.
ComponentRoles and labels ("3 of 8"), inert on hidden slides, aria-live switching, controlled mode snapping back, aria-disabled at the ends, tablist arrow keys and RTL.Testing Library with fake timers; matchMedia and visibilityState mocked.
BrowserReal swipes with touch and mouse, vertical scroll not blocked, click suppressed after a drag, no flash at the loop jump.Playwright with mobile device profiles.
VisualSlides per view at three container widths, RTL, peeking, placeholders.Screenshot tests in Playwright.
AccessibilityAutomated rule checks, then manual passes with VoiceOver, NVDA, and TalkBack.axe in CI; a manual checklist before releases.
PerformanceLCP and CLS on the product page; no layout or paint during a recorded swipe at 4x slowdown.Lighthouse CI, DevTools traces, field data from real users.
ts
test("a short, fast flick goes to the next slide", () => {
  expect(release({ dx: -40, velocity: -0.6, width: 390 })).toBe(+1);
});

test("a long, slow drag goes to the next slide", () => {
  expect(release({ dx: -120, velocity: -0.05, width: 390 })).toBe(+1);
});

test("a short, slow drag snaps back", () => {
  expect(release({ dx: -60, velocity: -0.1, width: 390 })).toBe(0);
});

test("dragging far and then flicking back is a change of mind", () => {
  expect(release({ dx: -200, velocity: 0.5, width: 390 })).toBe(0);
});
The release decision is a pure function, so its rules are cheap to pin down

Running it in the interview

You can't cover all of this guide in 45 minutes, and you shouldn't try. The interviewer wants to see that you know where the hard parts are, can pick two or three of them, and can go deep with numbers and edge cases. Plan the time, draw early, and keep checking which way the interviewer leans.

A 45-minute interview plan. Minutes 0 to 5: requirements. 5 to 10: the component API. 10 to 15: the layout decision and the anatomy drawing. 15 to 32: two or three deep dives, chosen by the product. 32 to 40: looping, responsive, and performance briefly. 40 to 45: testing and questions.needsAPIlayoutdeep divesthe resttests051015324045pick 2 or 3 by productminutes
The deep dives get the most time. Pick them by product: images and LCP for a product page, gestures and the render window for a feed, autoplay and accessibility for a hero.

Draw the anatomy early, around minute 10: viewport, track, slides with the render window marked, and the controls underneath. Almost every later point is easier to explain by pointing at it: where the transform goes, which slides are mounted, which ones are inert, where the live region sits. A drawing also lets the interviewer interrupt you with "what happens when the finger lands here?", which is the conversation you want.

  1. Minutes 0 to 5, requirements. Ask about slide content, count, autoplay, looping, slides per view, and whether the first slide is above the fold. Write the four numbers in a corner: LCP 2.5 s, CLS 0, 16 ms per frame, 2 or 3 images per idle carousel.
  2. Minutes 5 to 10, the API. Write the props: items and renderSlide, index with defaultIndex and onIndexChange with a reason, loop, autoplay, slidesPerView, aspectRatio. Say "controlled and uncontrolled, like an input" and move on.
  3. Minutes 10 to 15, the layout decision. Draw the anatomy: viewport, track, slides, controls. Name the three layouts, pick the transform track, and say when scroll snap would win.
  4. Minutes 15 to 32, two or three deep dives. Let the interviewer choose, or pick by product: for a product page, images and LCP; for a mobile feed, gestures and the render window; for a hero, autoplay and accessibility.
  5. Minutes 32 to 40, the rest briefly. Looping and its edge cases, responsive and RTL, performance. One or two sentences each, to show you know they exist.
  6. Minutes 40 to 45, testing and questions. Which logic is pure and unit-tested, what needs a real browser, and how you'd know in production that it works.

Things worth saying out loud

  • "The first slide is the LCP element, so it's server-rendered with fetchpriority="high", never lazy. Everything else waits."
  • "During a drag, React doesn't render. I write the transform directly; state changes at the start and end of the gesture."
  • "I don't decide scroll direction in JavaScript. touch-action: pan-y lets the browser decide on the compositor, and I handle pointercancel."
  • "Hidden slides are inert; slides are labeled 3 of 8; the live region is off while autoplay runs."
  • "Autoplay has a set of pause reasons and a user stop, because a single boolean breaks when hover and a tab switch overlap."

Common mistakes

  • Spending 15 minutes on arrow styles and dots before mentioning images or input.
  • Putting loading="lazy" on every slide, including the first, and calling it done.
  • Mounting all slides and saying "it's only images" without counting the decoded memory.
  • setInterval for autoplay with no pause button.
  • Never mentioning the vertical scroll conflict, which is the first thing a phone user notices.

Follow-up questions to expect

Think first

What does the user see before JavaScript loads, and can they swipe?

Think first

The product page wants a strip of 12 thumbnails under the gallery that scrolls on its own. How does it fit?

Think first

Slide 3 is a video. What changes?

Think first

How would you know in production that the carousel works well?

Summary

  • The API is the design: items plus renderSlide for any content, controlled and uncontrolled index like an input, and onIndexChange with a reason so analytics and syncing can tell a swipe from autoplay.
  • A transform track gives every team loop, paging, autoplay, and a known index; scroll snap is the better tool for a simple non-looping row. Clip the viewport with overflow: clip so focus can't scroll it.
  • A render window mounts the current slide plus a margin; other slides are empty boxes of the same width. It is also the preloader: mounting the real img makes the browser pick the same srcset file.
  • The first slide is often the LCP element: server-rendered img, fetchpriority="high", never lazy. Reserve the box with aspect-ratio for zero CLS, and compute sizes from slides per view.
  • Let the browser decide scroll direction with touch-action: pan-y pinch-zoom, handle pointercancel, and commit a swipe on distance past 25% or a fast flick in the same direction.
  • Never re-render React per frame during a drag. Write the transform directly, measure on resize only, and keep will-change on the moving track alone.
  • Looping with clones needs an invisible jump: compute the window with modulo so the real slide is mounted and decoded before the swap, and handle fast repeated presses.
  • Autoplay is opt-in, stops on focus and manual navigation, pauses on hover, hidden tabs, and off screen, respects reduced motion, has a visible pause button, and turns the live region off while it rotates.
  • Follow the APG carousel pattern: a named region with aria-roledescription="carousel", slides labeled "3 of 8", hidden slides inert, and dots as one-stop tablists.

Check your answer

Tick what your own design covered. Pick the level you're interviewing for: each level includes the ones before it.

Junior 0 of 4
Mid-level 0 of 5

References

Concepts behind it

Try next