The image carousel is a staple frontend system design question — asked at Amazon, Google, Dropbox, and Microsoft. It seems simple ("show images one at a time with prev/next buttons") but a complete answer covers layout, performance, touch support, autoplay, edge cases, and accessibility.
This article walks through a full RADIO-framework answer. If you're unfamiliar with RADIO, start here.
R — Requirements
Functional requirements:
- Display a list of images, one at a time
- Prev / Next buttons to navigate between images
- Progress dots indicating current position; clickable to jump to any image
- Infinite cycling — Next from the last image wraps to the first, and vice versa
- Smooth horizontal transition animation between images
Non-functional requirements:
- Works on desktop, tablet, and mobile (touch/swipe)
- Interactive elements are large enough to tap on mobile (≥44×44px)
- Images load lazily — don't download off-screen images upfront
- Preload the next image to avoid a flash of blank
- Accessible to screen reader and keyboard users
Extended requirements (if time allows):
- Autoplay with configurable interval
- Pause autoplay on hover / focus
- Custom event callbacks (
onNext,onPrev,onSelect) - Theming / custom styling
Out of scope:
- Server-fetched images (images are provided as a config array)
- Video support
A — Architecture
The carousel is a pure client-side component with no server interaction. It follows a unidirectional data flow: user actions trigger state changes, state changes drive renders.
┌─────────────────────────────────────────────────┐
│ <ImageCarousel> │
│ │
│ ┌───────────────────────────────────────────┐ │
│ │ <CarouselTrack> │ │
│ │ [img0] [img1] [img2] [img3] ... │ │
│ │ ←────── CSS flex, overflow hidden ──────→│ │
│ └───────────────────────────────────────────┘ │
│ │
│ ┌──────────┐ ┌──────────────────┐ │
│ │ <PrevBtn> │ │ <NextBtn> │ │
│ └──────────┘ └──────────────────┘ │
│ │
│ ┌───────────────────────────────────────────┐ │
│ │ <ProgressDots> │ │
│ │ ● ○ ○ ○ ○ (filled = active) │ │
│ └───────────────────────────────────────────┘ │
└─────────────────────────────────────────────────┘
│
│ useCarousel() hook
│
┌────────┴────────────────────────────────────────┐
│ State: activeIndex, isPlaying │
│ Actions: prev(), next(), goTo(index), toggle() │
│ Effects: autoplay timer, keyboard listeners │
└─────────────────────────────────────────────────┘Components:
ImageCarousel— root, composes everything, owns ref to track elementCarouselTrack— horizontal flex container of all imagesPrevBtn/NextBtn— navigation buttonsProgressDots— dot indicators with click navigationAutoplayToggle— optional play/pause button
Logic (hook):
useCarousel({ images, autoplayMs, loop })— encapsulates all state and actions
A Flux-style reducer works well here since multiple action sources (button clicks, keyboard, timer, touch) all modify the same activeIndex:
type CarouselAction =
| { type: 'NEXT' }
| { type: 'PREV' }
| { type: 'GO_TO'; index: number }
| { type: 'TOGGLE_AUTOPLAY' };
function carouselReducer(state: CarouselState, action: CarouselAction): CarouselState {
const { activeIndex, imageCount } = state;
switch (action.type) {
case 'NEXT':
return { ...state, activeIndex: (activeIndex + 1) % imageCount };
case 'PREV':
return { ...state, activeIndex: (activeIndex - 1 + imageCount) % imageCount };
case 'GO_TO':
return { ...state, activeIndex: action.index };
case 'TOGGLE_AUTOPLAY':
return { ...state, isPlaying: !state.isPlaying };
}
}D — Data Model
Configuration (Props)
type CarouselImage = {
src: string;
alt: string;
srcSet?: string; // For responsive images
sizes?: string;
};
interface ImageCarouselProps {
images: CarouselImage[];
transitionDuration?: number; // ms, default 300
autoplayMs?: number; // 0 = disabled, default 0
loop?: boolean; // Default true
height?: number; // px
width?: number; // px
// Callbacks
onNext?: (index: number) => void;
onPrev?: (index: number) => void;
onSelect?: (index: number) => void;
}Component State
type CarouselState = {
activeIndex: number;
imageCount: number;
isPlaying: boolean; // Autoplay running
};I — Interface
Internal Actions
interface CarouselControls {
prev: () => void; // Prev button, keyboard ←, swipe right
next: () => void; // Next button, keyboard →, swipe left, autoplay timer
goTo: (index: number) => void; // Progress dot click
toggleAutoplay: () => void; // Play/pause button
}Component API Example (React)
<ImageCarousel
images={[
{ src: '/img/paris.jpg', alt: 'Eiffel Tower at sunset' },
{ src: '/img/tokyo.jpg', alt: 'Tokyo skyline at night' },
{ src: '/img/nyc.jpg', alt: 'Manhattan skyline' },
]}
transitionDuration={300}
autoplayMs={4000}
height={500}
width={900}
onSelect={(index) => analytics.track('carousel_slide', { index })}
/>O — Optimisations
1. CSS Layout: Flex + Scroll
The cleanest layout uses a flex container that's wider than its viewport, with overflow: hidden to clip unseen images:
.carousel-track {
display: flex;
overflow: hidden; /* Clip images outside viewport */
width: 900px;
height: 500px;
}
.carousel-slide {
flex: 0 0 900px; /* Fixed size, no shrink/grow */
height: 500px;
}To navigate, shift the track using transform: translateX:
trackRef.current.style.transform = `translateX(-${activeIndex * slideWidth}px)`;
trackRef.current.style.transition = `transform ${transitionDuration}ms ease-in-out`;This is faster than changing scrollLeft — transform is composited on the GPU and never triggers layout.
2. Image Fit: object-fit
Images won't be the exact carousel dimensions. Use object-fit to handle this gracefully:
.carousel-slide img {
width: 100%;
height: 100%;
object-fit: cover; /* Fill the space, crop if needed */
/* or: object-fit: contain; Letterbox, no crop */
}Allow this to be configurable — product teams will have opinions.
3. Lazy Loading + Smart Preloading
Lazy load all images except the first (don't waste bandwidth on images the user may never see):
<img
src={image.src}
alt={image.alt}
loading={index === 0 ? 'eager' : 'lazy'}
/>Preload the next image when the user shows intent to navigate (hover over button, focus on button, or just always preload activeIndex + 1):
function preloadImage(src: string) {
const img = new Image();
img.src = src;
}
// Preload next on hover
nextBtnRef.current?.addEventListener('mouseenter', () => {
const nextIndex = (activeIndex + 1) % images.length;
preloadImage(images[nextIndex].src);
});Airbnb's approach: preload the 2nd image only after the user interacts with the carousel (hovers or tabs to it), then preload the next 3 after they view the 2nd. This balances bandwidth with UX.
4. CSS Scroll Snap (Alternative Approach)
An even simpler layout uses native scroll snapping — especially good for mobile swipe:
.carousel-track {
display: flex;
overflow-x: auto;
scroll-snap-type: x mandatory;
scroll-behavior: smooth;
-webkit-overflow-scrolling: touch; /* iOS momentum scrolling */
scrollbar-width: none; /* Hide scrollbar */
}
.carousel-slide {
scroll-snap-align: start;
flex: 0 0 100%;
}Navigation:
function goTo(index: number) {
trackRef.current?.scrollTo({ left: index * slideWidth, behavior: 'smooth' });
}Pros: Mobile swipe works for free, zero JS for animation
Cons: Harder to sync dot state with scroll position (need IntersectionObserver)
5. Touch / Swipe Support
For the transform-based approach, add touch gesture handling:
let touchStartX = 0;
trackRef.current?.addEventListener('touchstart', (e) => {
touchStartX = e.touches[0].clientX;
}, { passive: true });
trackRef.current?.addEventListener('touchend', (e) => {
const delta = touchStartX - e.changedTouches[0].clientX;
if (Math.abs(delta) > 50) { // 50px threshold
delta > 0 ? next() : prev();
}
});6. Autoplay
useEffect(() => {
if (!autoplayMs || !isPlaying) return;
const timer = setInterval(() => {
dispatch({ type: 'NEXT' });
}, autoplayMs);
return () => clearInterval(timer);
}, [autoplayMs, isPlaying, activeIndex]);Pause on hover/focus — animating content is distracting when users are trying to interact:
<div
onMouseEnter={() => setIsPlaying(false)}
onMouseLeave={() => setIsPlaying(true)}
onFocusCapture={() => setIsPlaying(false)}
onBlurCapture={() => setIsPlaying(true)}
>Also: stop autoplay on any manual navigation, restart the timer when the user stops interacting (reset the interval on each goTo call).
7. Keyboard Navigation
useEffect(() => {
function handleKey(e: KeyboardEvent) {
if (e.key === 'ArrowRight') next();
if (e.key === 'ArrowLeft') prev();
if (e.key === 'Home') goTo(0);
if (e.key === 'End') goTo(images.length - 1);
}
window.addEventListener('keydown', handleKey);
return () => window.removeEventListener('keydown', handleKey);
}, []);8. Responsive Images
Serve appropriately sized images per device:
<img
src={image.src}
srcSet={`${image.src}?w=400 400w, ${image.src}?w=800 800w, ${image.src}?w=1200 1200w`}
sizes="(max-width: 600px) 400px, (max-width: 1000px) 800px, 1200px"
alt={image.alt}
/>Or use the <picture> element for art direction (different crops per breakpoint).
9. Accessibility (ARIA)
Image carousels are notoriously inaccessible. Do it right:
<section
aria-label="Image carousel"
aria-roledescription="carousel"
>
{/* Announce current slide to screen readers */}
<div aria-live="polite" aria-atomic="true" className="sr-only">
Slide {activeIndex + 1} of {images.length}: {images[activeIndex].alt}
</div>
{/* Track */}
<div role="group" aria-label={`Slide ${activeIndex + 1} of ${images.length}`}>
{images.map((img, i) => (
<div
key={i}
role="group"
aria-roledescription="slide"
aria-label={`Slide ${i + 1}: ${img.alt}`}
aria-hidden={i !== activeIndex}
>
<img src={img.src} alt={img.alt} />
</div>
))}
</div>
{/* Buttons */}
<button aria-label="Previous slide" onClick={prev}>‹</button>
<button aria-label="Next slide" onClick={next}>›</button>
{/* Dots */}
<div role="tablist" aria-label="Choose slide">
{images.map((_, i) => (
<button
key={i}
role="tab"
aria-selected={i === activeIndex}
aria-label={`Go to slide ${i + 1}`}
onClick={() => goTo(i)}
/>
))}
</div>
{/* Autoplay toggle */}
{autoplayMs && (
<button
aria-label={isPlaying ? 'Pause autoplay' : 'Start autoplay'}
onClick={toggleAutoplay}
>
{isPlaying ? '⏸' : '▶'}
</button>
)}
</section>Key points:
aria-live="polite"announces slide changes to screen readersaria-hiddenhides off-screen slides from the accessibility tree- Buttons have descriptive
aria-label(not just<and>) - Progress dots use
role="tablist"/role="tab"witharia-selected - Autoplay toggle has dynamic
aria-labelreflecting current state
10. Prevent Layout Shift
Set explicit dimensions on the carousel wrapper to prevent layout shift (CLS) while images load:
.carousel-wrapper {
width: 900px;
height: 500px;
background: #f0f0f0; /* Placeholder colour */
position: relative;
}Use aspect-ratio for responsive layouts:
.carousel-wrapper {
width: 100%;
aspect-ratio: 16 / 9;
}Summary
A complete image carousel system design covers:
- Requirements: infinite cycling, animated transitions, progress dots, mobile swipe, lazy load, accessibility
- Architecture:
CarouselTrack+PrevBtn+NextBtn+ProgressDots, all driven byuseCarouselhook with a Flux reducer for clean multi-source action handling - Data model:
CarouselImage[]config,activeIndex+isPlayingstate - Interface:
prev(),next(),goTo(index),toggleAutoplay()actions;onNext/onPrev/onSelectcallbacks - Optimisations: GPU-composited
transformanimation,object-fit: coverfor image fitting, lazy loading + smart preloading, CSS scroll snap for mobile, touch gesture handling, pause-on-hover autoplay, keyboard arrows, responsivesrcSet, full ARIA carousel pattern, explicit dimensions to prevent CLS