Hey folks, Rahul here ๐
Instagram stories, Airbnb listing photos, e-commerce product galleries โ image carousels are one of the most common UI patterns, and one of the most frequently botched. Bad carousels jank on swipe, preload every image upfront (killing Core Web Vitals), break on touch devices, or trap keyboard focus.
Let's build one that's buttery smooth, memory-efficient, and accessible.
R โ Requirements
Functional Requirements
- Horizontal slide navigation (prev/next buttons + swipe gestures)
- Dot indicators or thumbnail strip
- Auto-play with pause on hover/focus
- Lightbox mode (fullscreen with zoom/pan)
- Lazy loading of off-screen images
- Loop / no-loop modes
- Support for mixed media (images + videos)
Non-Functional Requirements
- Performance: 60fps slide transitions, no layout shift (CLS = 0)
- Loading: Only load current + adjacent slides
- Accessibility: ARIA live region, roledescription, keyboard nav
- Touch: Smooth swipe with momentum, snap to slide
- Memory: Handle 1000+ images without OOM
A โ Architecture
Rendering Approaches
Approach 1: CSS Scroll Snap (Modern, Recommended) โ
// Let the browser handle scroll physics โ it's smoother than JS
function CSSSnapCarousel({ items }: { items: CarouselItem[] }) {
const containerRef = useRef<HTMLDivElement>(null);
const [activeIndex, setActiveIndex] = useState(0);
// Track which slide is snapped
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const observer = new IntersectionObserver(
(entries) => {
entries.forEach(entry => {
if (entry.isIntersecting && entry.intersectionRatio > 0.5) {
const index = Number(entry.target.getAttribute('data-index'));
setActiveIndex(index);
}
});
},
{ root: container, threshold: 0.5 }
);
container.querySelectorAll('[data-index]').forEach(el => observer.observe(el));
return () => observer.disconnect();
}, [items.length]);
return (
<div
ref={containerRef}
className="flex overflow-x-auto snap-x snap-mandatory scrollbar-hide"
style={{ scrollBehavior: 'smooth' }}
role="region"
aria-roledescription="carousel"
aria-label="Image gallery"
>
{items.map((item, i) => (
<div
key={item.id}
data-index={i}
className="flex-none w-full snap-center"
role="group"
aria-roledescription="slide"
aria-label={`Slide ${i + 1} of ${items.length}`}
>
<CarouselSlide item={item} isActive={i === activeIndex} />
</div>
))}
</div>
);
}Approach 2: Transform-Based (More Control)
// For custom physics or complex animations
function TransformCarousel({ items }: { items: CarouselItem[] }) {
const [offset, setOffset] = useState(0);
const [isDragging, setIsDragging] = useState(false);
const dragStart = useRef(0);
const dragOffset = useRef(0);
const goTo = (index: number) => {
setOffset(-index * 100); // Percentage-based
};
return (
<div className="overflow-hidden">
<div
className={cn("flex", !isDragging && "transition-transform duration-300")}
style={{ transform: `translateX(${offset}%)` }}
onPointerDown={handleDragStart}
onPointerMove={handleDragMove}
onPointerUp={handleDragEnd}
>
{items.map((item, i) => (
<div key={item.id} className="flex-none w-full">
<CarouselSlide item={item} />
</div>
))}
</div>
</div>
);
}Component Tree
ImageGallery
โโโ CarouselContainer // Main slide area
โ โโโ CarouselSlide[] // Individual slides
โ โ โโโ LazyImage // Intersection-based loading
โ โโโ PrevButton / NextButton // Arrow navigation
โ โโโ SwipeHandler // Touch gesture detection
โโโ Indicators // Dots or thumbnail strip
โ โโโ ThumbnailStrip // Scrollable thumbnails
โโโ Lightbox (Portal) // Fullscreen overlay
โ โโโ ZoomableImage // Pinch-zoom + pan
โ โโโ LightboxControls
โ โโโ LightboxThumbnails
โโโ AutoplayController // Timer + pause logicD โ Data Model
interface CarouselItem {
id: string;
type: 'image' | 'video';
src: string;
srcSet?: string; // Responsive sources
thumbnail?: string; // Low-res placeholder
blurHash?: string; // BlurHash for skeleton
alt: string;
width: number;
height: number; // For aspect ratio (prevents CLS)
caption?: string;
}
interface CarouselState {
activeIndex: number;
totalSlides: number;
isAutoPlaying: boolean;
isLightboxOpen: boolean;
lightboxIndex: number;
loadedIndices: Set<number>; // Track which images are loaded
direction: 'left' | 'right'; // For animation direction
}Lazy Loading Strategy
// Only load: current slide, previous slide, next slide
function useAdjacentPreload(activeIndex: number, total: number) {
const [loaded, setLoaded] = useState<Set<number>>(new Set([0]));
useEffect(() => {
const toLoad = new Set(loaded);
toLoad.add(activeIndex);
if (activeIndex > 0) toLoad.add(activeIndex - 1);
if (activeIndex < total - 1) toLoad.add(activeIndex + 1);
setLoaded(toLoad);
}, [activeIndex, total]);
return loaded;
}
function LazyImage({ item, shouldLoad }: { item: CarouselItem; shouldLoad: boolean }) {
if (!shouldLoad) {
// Show blurHash placeholder or aspect-ratio skeleton
return (
<div
className="bg-muted animate-pulse"
style={{ aspectRatio: `${item.width}/${item.height}` }}
/>
);
}
return (
<img
src={item.src}
srcSet={item.srcSet}
alt={item.alt}
loading="lazy"
decoding="async"
className="w-full h-full object-cover"
style={{ aspectRatio: `${item.width}/${item.height}` }}
/>
);
}I โ Interface Definition
Touch Gesture Handler
function useSwipeGesture(onSwipe: (dir: 'left' | 'right') => void) {
const startX = useRef(0);
const startY = useRef(0);
const isDragging = useRef(false);
const handlers = {
onTouchStart: (e: React.TouchEvent) => {
startX.current = e.touches[0].clientX;
startY.current = e.touches[0].clientY;
isDragging.current = true;
},
onTouchEnd: (e: React.TouchEvent) => {
if (!isDragging.current) return;
isDragging.current = false;
const endX = e.changedTouches[0].clientX;
const endY = e.changedTouches[0].clientY;
const deltaX = endX - startX.current;
const deltaY = endY - startY.current;
// Only trigger if horizontal swipe is dominant
if (Math.abs(deltaX) > Math.abs(deltaY) && Math.abs(deltaX) > 50) {
onSwipe(deltaX > 0 ? 'right' : 'left');
}
},
};
return handlers;
}Lightbox with Zoom/Pan
O โ Optimizations
1. Prevent Cumulative Layout Shift
2. Autoplay with Accessibility
3. Virtualized Gallery for 1000+ Images
Production Gotchas Rahul Has Debugged ๐ฅ
- Scroll Snap + Dynamic Content: If slide dimensions change after snap (image loads, text expands), the snap position drifts. Set explicit dimensions on containers, not content.
- Swipe vs. Scroll: Horizontal carousels can hijack vertical page scroll. Only capture the gesture if the horizontal delta is significantly larger than the vertical delta (2:1 ratio).
- Auto-play Accessibility: WCAG 2.2.2 requires a pause mechanism for any auto-moving content. Always provide a visible pause button and respect
prefers-reduced-motion. - Image Decode Jank: Large images decoded on the main thread cause frame drops during transitions. Use
img.decode()to decode off-thread before displaying, or usedecoding="async". - Object URL Leaks in Lightbox: If you create blob URLs for zoom functionality, always
URL.revokeObjectURL()on cleanup.
Next: #15: Design a Spreadsheet / Data Grid โ virtual scrolling in 2D, cell selection state, formula parsing, and copy-paste interop. ๐