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.
| Question | Answer we assume | What 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. |
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
- 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.
- 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.
- 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.
- 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.
- 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.
- 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?
Loading and hiding break first. With a plain flex row every slide's img is in the DOM, so the browser may fetch all of them at once, competing with the first image and hurting LCP. Every hidden link is still tabbable, and a screen reader reads all slides.
Touch is next. Without pointer handling and touch-action, there's no swipe at all, or a swipe that fights the page's vertical scroll. The transform itself is fine: it's the right way to move things. The afternoon version gets the easy 20% right.
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?
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.
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 };
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.
| Team | Props they pass | What they get |
|---|---|---|
| Product page | index, onIndexChange, renderControls with thumbnails, aspectRatio="4 / 5" | A gallery in sync with the color picker, no loop, no autoplay. |
| Home page hero | loop, autoplay={{ intervalMs: 6000 }}, aspectRatio="16 / 6" | A rotating banner with a pause button and the right live region settings. |
| Feed post | defaultIndex={0}, aspectRatio from the first photo | A lazy carousel that costs almost nothing until the post is near the screen. |
| Related products | slidesPerView={{ "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.
Decision What moves when the user goes to the next slide?
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:
// 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)`;
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?
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.
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.
<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 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 CSSbackground-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 calldecode()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.
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?
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?
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.
touch-action: pan-y. Our job is the sideways path, and handling pointercancel when the browser claims the gesture.The drag, step by step
pointerdown: remember the start point and time, stop autoplay, cancel any running transition by reading the track's current position, and callsetPointerCaptureso moves keep coming even if the pointer leaves the carousel.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.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.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.- After a drag: swallow the
clickthat followspointerup, 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 withdraggable="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.
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?
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.
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
| Case | What goes wrong | Fix |
|---|---|---|
| Next pressed 4 times in 300 ms | The 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 frame | The 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 5 | Going "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 slides | With 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 view | There'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 clone | It 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?
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 withmatchMediaand listen for itschangeevent. 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
visibilitychangeand checkdocument.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
IntersectionObserverpauses 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.
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");
});
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.
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
sectionelement witharia-roledescription="carousel"andaria-label="Product photos". Asectionwith 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", andaria-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 torole="tabpanel"and drops their role description. - Previous and next are
buttonelements labeled "Previous slide" and "Next slide", witharia-controlspointing 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 andaria-live="off"while rotating, so manual changes are announced and automatic ones aren't. - Hidden slides get the
inertattribute. It removes them from the tab order and from the accessibility tree in one step, with no need to hunt for every link and settabindex="-1". It works in all current browsers.
<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>
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.
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?
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?
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.
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 iscalc((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.
- The transform. Moving to slide 2 now means moving the track to the right, so the offset's sign flips. That's the
rtlbranch in thetrackXfunction in the layout chapter. - The swipe. Dragging the content to the right reveals the next slide, so the drag's sign flips before the threshold rules run.
- The keys and icons. In the dots' tablist, the Left arrow means next. The buttons use logical CSS,
inset-inline-startandinset-inline-endinstead ofleftandright, 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?
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.
requestIndex(next, reason). That one door is why controlled mode and the change reason are always right.| Part | Responsibility | Why the boundary is here |
|---|---|---|
| Index state | Holds 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 controller | Pointer events, slop, direction, rubber band, the release decision, click suppression. | Runs outside React on every frame. Calls requestIndex once per gesture. |
| Track | Writes 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 window | Decides 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 controller | Pause 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 wrapper | Role, role description, "3 of 8" label, inert, the box size. | Teams render content; the wrapper guarantees the semantics. |
| Controls | Default buttons, rotation button, dots as a tablist; or the team's own through renderControls. | Built on the public CarouselApi only. |
CarouselImage | srcset, 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.
- Idle to dragging on
pointerdownplus 8 pixels of horizontal movement. Autoplay gets thedragpause. - Settling to dragging when a finger lands mid-transition: read the track's real position with
getComputedStyleand 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
transitionendor the fallback timer. FireonSettle, 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.
// 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 };
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. CallinggetBoundingClientRect()oroffsetWidthafter a style write forces the browser to run layout right then. Measure the slide width once, in aResizeObservercallback, 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
requestAnimationFramecallback. - Small work on release. Taps on the arrows and dots are interactions that INP measures, and after a swipe, the work on
pointerupdelays the first frame of the settle animation. CallingonIndexChangeand 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: transformbecomes the containing block forposition: fixedchildren. A team's "fixed" tooltip inside a slide suddenly scrolls with the slide. - A stacking context.
z-indexinside 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.
| Layer | What it checks | Tools |
|---|---|---|
| Unit | Release 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. |
| Component | Roles 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. |
| Browser | Real 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. |
| Visual | Slides per view at three container widths, RTL, peeking, placeholders. | Screenshot tests in Playwright. |
| Accessibility | Automated rule checks, then manual passes with VoiceOver, NVDA, and TalkBack. | axe in CI; a manual checklist before releases. |
| Performance | LCP 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. |
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);
});
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.
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.
- 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.
- Minutes 5 to 10, the API. Write the props:
itemsandrenderSlide,indexwithdefaultIndexandonIndexChangewith a reason,loop,autoplay,slidesPerView,aspectRatio. Say "controlled and uncontrolled, like an input" and move on. - 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.
- 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.
- 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.
- 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-ylets the browser decide on the compositor, and I handlepointercancel." - "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.
setIntervalfor 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?
The server renders the viewport with its aspect ratio, the first slide's image, and the controls. The first image starts loading straight from the HTML, so LCP doesn't wait for our script. With a transform track, there's no swiping until the page hydrates. Our 8 KB adds little to that wait; how long it lasts depends on everything else the page loads.
If the product needs swiping before hydration, for example on very slow devices, the server can render the row as a scroll snap container, and the script upgrades it to the transform track on hydration. That costs a second layout path to maintain, so I'd only do it if field data showed people swiping before hydration.
Think first
The product page wants a strip of 12 thumbnails under the gallery that scrolls on its own. How does it fit?
The thumbnails are a custom control through renderControls, built as a tablist on the same CarouselApi, so selecting one calls goTo. The strip itself is a plain horizontal scroll snap row, which is the right tool for a simple non-looping list. When the index changes, the active thumbnail is scrolled into view with scrollIntoView({ block: "nearest", inline: "nearest" }).
Thumbnails get their own small sizes, about 80 pixels, so each costs a few kilobytes. They can use loading="lazy": whether a browser loads the off-screen ones early or only when they scroll into view, an 80-pixel thumbnail is cheap and quick either way.
Think first
Slide 3 is a video. What changes?
The slide shows a poster image through CarouselImage, so loading and layout don't change. The video element mounts only when slide 3 is active and the user presses play, with playsinline so it plays inside the slide on iPhones instead of going full screen. It pauses when the slide stops being active, which the slide learns from info.isActive.
Two interactions need rules. A playing video adds a pause reason to autoplay, so the hero doesn't rotate away mid-video. And the slide asks to stay mounted with keepMounted while playing, so swiping one slide away and back doesn't lose the playback position.
Think first
How would you know in production that the carousel works well?
Core Web Vitals from real users, with attribution: how often the carousel's first image is the LCP element and its LCP time, and its share of layout shifts, which should be zero. Long animation frames recorded while a drag is active, as a share of drags. Engagement: swipes and arrow presses per view, the deepest slide reached, and how often people swipe past slide 1 at all. Image errors from onerror, by CDN region.
Those numbers turn opinions into decisions. If almost nobody reaches slide 4 in the hero, the marketing team learns something useful, and we learn that preloading slide 4 would be waste.
Summary
- The API is the design:
itemsplusrenderSlidefor any content, controlled and uncontrolled index like aninput, andonIndexChangewith 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: clipso 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
imgmakes the browser pick the samesrcsetfile. - The first slide is often the LCP element: server-rendered
img,fetchpriority="high", never lazy. Reserve the box withaspect-ratiofor zero CLS, and computesizesfrom slides per view. - Let the browser decide scroll direction with
touch-action: pan-y pinch-zoom, handlepointercancel, 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-changeon 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 slidesinert, 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.
References
- WAI-ARIA Authoring Practices: Carousel pattern Roles, labels, the rotation control, and the basic, tabbed, and grouped variants.
- Understanding WCAG 2.2: Pause, Stop, Hide (2.2.2) Why auto-moving content needs a way to pause it.
- MDN: CSS scroll snap
scroll-snap-type,scroll-snap-align,scroll-snap-stop, andscroll-paddingfor the native layout option. - MDN: touch-action How
pan-yandpinch-zoomhand gestures to the browser. - MDN: Pointer events One event model for mouse, touch, and pen, including
pointercanceland pointer capture. - web.dev: Optimize resource loading with the Fetch Priority API When
fetchpriority="high"helps the LCP image. - web.dev: Browser-level image lazy loading How native lazy loading decides distance, how Chrome 121 changed it for horizontal rows, and why not to lazy-load the LCP image.
- web.dev Learn Responsive Design: Responsive images
srcset,sizes, and choosing widths. - web.dev: Interaction to Next Paint (INP) Which interactions INP measures, and its thresholds.
- MDN: will-change Why to use it sparingly.
- MDN: The inert attribute Removing hidden slides from focus and the accessibility tree.
Concepts behind it
- Images and mediaSending each user an image of the right size and format, reserving its space, and loading it at the right moment: images are usually the largest share of a page's bytes and its LCP element.
- Core Web VitalsThree numbers, measured on real users, that say whether a page loads fast (LCP), reacts fast (INP), and stays still (CLS): what each one measures, what makes it bad, and how to fix it.
- Designing component APIsHow to shape a component's props, children, slots, events, and ref so other engineers can use it for cases you didn't predict, without it growing a hundred props: composition, compound components, controlled state, and headless behavior.
- ARIA widget patternsThe tested recipes for custom widgets from the ARIA Authoring Practices Guide: combobox, listbox, menu, tabs, dialog, tree, grid, disclosure, and accordion, with the roles, states, and keys each one promises, and how to pick the right one.
- Keyboard navigationMaking every action work without a mouse: a Tab order that follows the page, a focus ring you can always see, arrow keys inside composite widgets, skip links past repeated content, and no place where focus gets stuck.
- Right-to-left layoutsHow to build layouts that mirror correctly for Arabic, Hebrew, Persian, and Urdu readers: the
dirattribute, CSS logical properties, which icons to flip, text that mixes both directions, and how to test all of it. - Runtime performanceKeeping the page responsive after it loads: finding long tasks on the main thread, splitting or moving that work, and avoiding needless re-renders so every click and keypress paints in under 200 ms.