Image Carousel
One full-bleed image at a time with a caption chip, arrows on the edges and a thumbnail strip that follows the active image. Fixed aspect ratio and lazy images, so nothing shifts.
Image carousel options
Installation
npx shadcn@latest add https://ui-collections.vercel.app/r/tide/image-carousel.jsonAlso installs the theme, cn() and carousel, button. Packages: clsx, tailwind-merge, class-variance-authority.
Usage
"use client";
import { useState } from "react";
import type { ControlVisibility } from "@/registry/kit/ui/carousel";
import { ImageCarousel, type ImageCarouselProps } from "@/registry/kit/ui/image-carousel";
import { ChoiceOption, OptionsPanel, RangeOption, SwitchOption } from "./carousel-options";
// Our own CC0 illustrations, shipped in public/media/gallery.
const images = [
{ src: "/media/gallery/northern-lights.webp", alt: "Green and violet northern lights over a dark pine forest", caption: "Aurora · 3200 × 2000", width: 1600, height: 1000 },
{ src: "/media/gallery/golden-coast.webp", alt: "A lighthouse on a green cliff above the sea at golden hour", caption: "Golden coast · 3200 × 2000", width: 1600, height: 1000 },
{ src: "/media/gallery/lake-dusk.webp", alt: "Snow-capped peaks mirrored in a still lake at dusk", caption: "Mirror lake · 3200 × 2000", width: 1600, height: 1000 },
{ src: "/media/gallery/desert-dunes.webp", alt: "Orange sand dunes under a bright midday sun", caption: "Midday dunes · 3200 × 2000", width: 1600, height: 1000 },
{ src: "/media/gallery/city-night.webp", alt: "A city skyline at night with lit windows reflected in the river", caption: "Night shift · 3200 × 2000", width: 1600, height: 1000 },
];
type Opt<K extends keyof ImageCarouselProps> = NonNullable<ImageCarouselProps[K]>;
const visibility = [
{ value: "always", label: "Always" },
{ value: "hover", label: "On hover" },
{ value: "never", label: "Hidden" },
] as const satisfies { value: ControlVisibility; label: string }[];
export default function ImageCarouselDemo() {
const [thumbnails, setThumbnails] = useState(true);
const [thumbPosition, setThumbPosition] = useState<Opt<"thumbnailPosition">>("bottom");
const [thumbSize, setThumbSize] = useState<Opt<"thumbnailSize">>("md");
const [autoplay, setAutoplay] = useState(false);
const [seconds, setSeconds] = useState(5);
const [pauseOnHover, setPauseOnHover] = useState(true);
const [loop, setLoop] = useState(true);
const [arrows, setArrows] = useState<ControlVisibility>("always");
const [placement, setPlacement] = useState<"overlay" | "outside" | "bottom">("overlay");
const [dots, setDots] = useState<ControlVisibility>("never");
const [indicator, setIndicator] = useState<Opt<"indicator">>("dots");
const [captions, setCaptions] = useState(true);
const [captionPosition, setCaptionPosition] = useState<Opt<"captionPosition">>("overlay-bottom");
const [aspectRatio, setAspectRatio] = useState("16 / 10");
const [fit, setFit] = useState<Opt<"fit">>("cover");
const [transition, setTransition] = useState<Opt<"transition">>("slide");
const [draggable, setDraggable] = useState(true);
const [wheel, setWheel] = useState(true);
const [keyboard, setKeyboard] = useState(true);
return (
<div className="grid w-full max-w-4xl gap-8">
<ImageCarousel
key={transition}
label="Wallpaper previews"
images={images}
thumbnails={thumbnails}
thumbnailPosition={thumbPosition}
thumbnailSize={thumbSize}
autoplay={autoplay ? seconds * 1000 : undefined}
pauseOnHover={pauseOnHover}
loop={loop}
arrows={arrows}
arrowPlacement={placement}
dots={dots}
indicator={indicator}
captions={captions}
captionPosition={captionPosition}
aspectRatio={aspectRatio}
fit={fit}
transition={transition}
draggable={draggable}
wheel={wheel}
keyboard={keyboard}
/>
<OptionsPanel title="Image carousel options">
<SwitchOption label="Thumbnails" checked={thumbnails} onChange={setThumbnails} />
<ChoiceOption label="Thumbnail position" value={thumbPosition} onChange={setThumbPosition} options={[{ value: "bottom", label: "Bottom" }, { value: "top", label: "Top" }, { value: "left", label: "Left" }, { value: "right", label: "Right" }]} />
<ChoiceOption label="Thumbnail size" value={thumbSize} onChange={setThumbSize} options={[{ value: "sm", label: "Small" }, { value: "md", label: "Medium" }, { value: "lg", label: "Large" }]} />
<SwitchOption label="Autoplay" checked={autoplay} onChange={setAutoplay} />
<RangeOption label="Interval" value={seconds} min={2} max={10} disabled={!autoplay} format={(v) => `${v} s`} onChange={setSeconds} />
<SwitchOption label="Pause on hover" checked={pauseOnHover} onChange={setPauseOnHover} />
<SwitchOption label="Loop" checked={loop} onChange={setLoop} />
<ChoiceOption label="Arrows" value={arrows} onChange={setArrows} options={visibility} />
<ChoiceOption label="Arrow placement" value={placement} onChange={setPlacement} options={[{ value: "overlay", label: "Overlay" }, { value: "outside", label: "Outside" }, { value: "bottom", label: "Bottom" }]} />
<ChoiceOption label="Indicator" value={dots} onChange={setDots} options={visibility} />
<ChoiceOption label="Indicator style" value={indicator} onChange={setIndicator} options={[{ value: "dots", label: "Dots" }, { value: "bars", label: "Bars" }, { value: "fraction", label: "2 / 5" }]} />
<SwitchOption label="Captions" checked={captions} onChange={setCaptions} />
<ChoiceOption label="Caption position" value={captionPosition} onChange={setCaptionPosition} options={[{ value: "overlay-bottom", label: "On image" }, { value: "below", label: "Below" }]} />
<ChoiceOption label="Aspect ratio" value={aspectRatio} onChange={setAspectRatio} options={[{ value: "16 / 10", label: "16:10" }, { value: "16 / 9", label: "16:9" }, { value: "4 / 3", label: "4:3" }, { value: "3 / 2", label: "3:2" }, { value: "1 / 1", label: "1:1" }]} />
<ChoiceOption label="Image fit" value={fit} onChange={setFit} options={[{ value: "cover", label: "Fill (crop)" }, { value: "contain", label: "Fit (whole)" }]} />
<ChoiceOption label="Transition" value={transition} onChange={setTransition} options={[{ value: "slide", label: "Slide" }, { value: "fade", label: "Fade" }]} />
<SwitchOption label="Swipe and drag" checked={draggable} onChange={setDraggable} />
<SwitchOption label="Wheel scrolling" checked={wheel} onChange={setWheel} />
<SwitchOption label="Arrow keys" checked={keyboard} onChange={setKeyboard} />
</OptionsPanel>
</div>
);
}API
labelstringAccessible name of the carousel.
images{ src?; art?; alt; caption?; width; height }[]Slides. width and height are the file's intrinsic size; alt is required. art draws a slide from a ReactNode (e.g. inline SVG in theme tokens) instead of a file.
apiRefRef<CarouselApi>goTo / next / prev, e.g. to show the photo of a chosen colour.
aspectRatiostringCSS aspect ratio of every slide and thumbnail, e.g. "16 / 9", "4 / 3", "3 / 2", "1 / 1".
fit"cover" | "contain"Crop to fill, or show the whole image on a deep backdrop.
thumbnailsbooleanThumbnail strip that follows the active image.
thumbnailPosition"bottom" | "top" | "left" | "right"Left and right are vertical strips from sm up and fall back to the bottom below.
thumbnailSize"sm" | "md" | "lg"Thumbnail width.
captionsbooleanShow each image's caption.
captionPosition"overlay-bottom" | "below"A chip on the image, or a line of text under it.
loopbooleanPrev on the first page goes to the last, next on the last to the first.
autoplaynumberMilliseconds per page. Always pauses on keyboard focus and hidden tabs.
pauseOnHoverbooleanAlso pause while the pointer is over the carousel.
showPlayPausebooleanVisible pause/play button while autoplay is on (WCAG 2.2.2).
arrows"always" | "hover" | "never"Previous and next buttons. Hover fades them in on hover or focus on pointer devices; touch screens always show them.
arrowPlacement"bottom" | "overlay" | "outside"Row under the track, centred on its edges, or beside it (from sm up; bottom below).
dots"always" | "hover" | "never"Page indicator visibility.
indicator"dots" | "bars" | "fraction"Dots and bars are buttons per page; fraction reads 2 / 6.
transition"slide" | "fade"Scroll the track, or cross-fade one slide at a time (fade swipes with a short drag).
draggablebooleanTouch swipe, trackpad and mouse drag with a grab cursor; a drag never clicks a slide. Off: only buttons, dots and keys move it.
wheelbooleanHorizontal wheel and trackpad scrolling.
keyboardbooleanArrow keys, Home and End while focus is inside.
onPageChange(page: number) => voidCalled with the 0-based page whenever it changes.
Source
"use client";
import { useEffect, useImperativeHandle, useRef, useState, type ReactNode, type Ref } from "react";
import { cn } from "@/registry/kit/lib/utils";
import { Carousel, type CarouselApi, type CarouselProps } from "@/registry/kit/ui/carousel";
export interface CarouselImage {
/** Image URL. Leave out when `art` draws the slide. */
src?: string;
/** Drawn artwork in place of a file, e.g. an inline SVG built from theme tokens. Fills the slide and its thumbnail. */
art?: ReactNode;
alt: string;
caption?: string;
/** Intrinsic size, so the browser reserves the space before the file arrives. */
width: number;
height: number;
}
type PassThrough = Pick<
CarouselProps,
"loop" | "autoplay" | "pauseOnHover" | "showPlayPause" | "arrows" | "arrowPlacement" | "dots" | "indicator" | "draggable" | "wheel" | "keyboard" | "transition" | "onPageChange"
>;
export interface ImageCarouselProps extends PassThrough {
/** Accessible name of the carousel. */
label: string;
images: CarouselImage[];
/** CSS aspect ratio of every slide and thumbnail. */
aspectRatio?: string;
/** "cover" crops to fill; "contain" shows the whole image on a deep backdrop. */
fit?: "cover" | "contain";
/** Strip of thumbnails that follows the active image. */
thumbnails?: boolean;
/** "left" and "right" are vertical strips from sm up and fall back to the bottom below. */
thumbnailPosition?: "bottom" | "top" | "left" | "right";
thumbnailSize?: "sm" | "md" | "lg";
captions?: boolean;
/** "overlay-bottom": a chip on the image; "below": a line of text under it. */
captionPosition?: "overlay-bottom" | "below";
/** Receives goTo / next / prev, e.g. to show the photo of a chosen colour. */
apiRef?: Ref<CarouselApi>;
className?: string;
}
const thumbWidth = { sm: "w-14 sm:w-16", md: "w-20 sm:w-24", lg: "w-24 sm:w-32" };
const sideWidth = { sm: "sm:w-[4.5rem]", md: "sm:w-[6.5rem]", lg: "sm:w-[8.5rem]" };
/**
* Full-bleed images one at a time with captions and an optional thumbnail
* strip, built on Carousel (swipe, drag, keys, autoplay, fade). Slides keep a
* fixed aspect ratio and images after the first load lazily, so nothing shifts.
*/
export function ImageCarousel({
label,
images,
aspectRatio = "16 / 10",
fit = "cover",
thumbnails = true,
thumbnailPosition = "bottom",
thumbnailSize = "md",
captions = true,
captionPosition = "overlay-bottom",
arrowPlacement = "overlay",
dots,
onPageChange,
apiRef,
className,
...carousel
}: ImageCarouselProps) {
const api = useRef<CarouselApi>(null);
useImperativeHandle(apiRef, () => ({
goTo: (page) => api.current?.goTo(page),
next: () => api.current?.next(),
prev: () => api.current?.prev(),
}));
const strip = useRef<HTMLUListElement>(null);
const [active, setActive] = useState(0);
const showStrip = thumbnails && images.length > 1;
const side = thumbnailPosition === "left" || thumbnailPosition === "right";
// Keep the active thumbnail in view by scrolling the strip only, never the page.
useEffect(() => {
const list = strip.current;
const item = list?.children[active] as HTMLElement | undefined;
if (!list || !item) return;
const behavior = "smooth";
const vertical = list.scrollHeight > list.clientHeight + 1 && list.scrollWidth <= list.clientWidth + 1;
if (vertical) {
const top = item.offsetTop - (list.clientHeight - item.offsetHeight) / 2;
list.scrollTo({ top: Math.max(0, top), behavior });
} else {
const left = Math.min(Math.max(0, item.offsetLeft - (list.clientWidth - item.offsetWidth) / 2), list.scrollWidth - list.clientWidth);
list.scrollTo({ left: getComputedStyle(list).direction === "rtl" ? -left : left, behavior });
}
}, [active]);
const thumbs = showStrip && (
<ul
ref={strip}
aria-label={`${label}: thumbnails`}
className={cn(
"relative flex gap-2 overflow-x-auto p-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
side && "sm:absolute sm:inset-0 sm:flex-col sm:overflow-x-hidden sm:overflow-y-auto",
)}
>
{images.map((image, i) => (
<li key={image.src ?? i} className="shrink-0">
<button
type="button"
aria-label={`Show image ${i + 1} of ${images.length}: ${image.alt}`}
aria-current={i === active ? "true" : undefined}
onClick={() => api.current?.goTo(i)}
className={cn(
"block cursor-pointer overflow-hidden rounded-[min(var(--radius-control),0.75rem)] bg-bg-deep outline-offset-2 transition-[opacity,scale] duration-300 ease-theme",
"hover:scale-(--hover-scale) focus-visible:outline-2 focus-visible:outline-accent active:scale-(--press-scale)",
thumbWidth[thumbnailSize],
side && "sm:w-full",
i === active ? "opacity-100 outline-2 outline-accent" : "opacity-55 hover:opacity-90",
)}
style={{ aspectRatio }}
>
{image.art ? (
<span aria-hidden className="grid size-full [&>*]:size-full">
{image.art}
</span>
) : (
// eslint-disable-next-line @next/next/no-img-element -- registry items can't import next/image.
<img src={image.src} alt="" width={image.width} height={image.height} loading="lazy" decoding="async" draggable={false} className={cn("size-full", fit === "cover" ? "object-cover" : "object-contain")} />
)}
</button>
</li>
))}
</ul>
);
return (
<div
className={cn(
"flex min-w-0 flex-col gap-3",
thumbnailPosition === "top" && "flex-col-reverse",
thumbnailPosition === "right" && "sm:flex-row",
thumbnailPosition === "left" && "sm:flex-row-reverse",
className,
)}
>
<Carousel
label={label}
arrowPlacement={arrowPlacement}
dots={dots ?? (showStrip ? "never" : "always")}
bleed="0px"
apiRef={api}
onPageChange={(p) => {
setActive(p);
onPageChange?.(p);
}}
className="min-w-0 flex-1"
{...carousel}
>
{images.map((image, i) => (
<figure key={image.src ?? i} className="m-0 flex min-w-0 flex-col">
<div className="relative overflow-hidden rounded-card bg-bg-deep" style={{ aspectRatio }}>
{image.art ? (
<div role="img" aria-label={image.alt} className="absolute inset-0 grid [&>*]:size-full">
{image.art}
</div>
) : (
// eslint-disable-next-line @next/next/no-img-element -- registry items can't import next/image.
<img
src={image.src}
alt={image.alt}
width={image.width}
height={image.height}
loading={i === 0 ? "eager" : "lazy"}
decoding="async"
draggable={false}
className={cn("absolute inset-0 size-full select-none", fit === "cover" ? "object-cover" : "object-contain")}
/>
)}
{captions && image.caption && captionPosition === "overlay-bottom" && (
<figcaption className="surface-raised absolute start-3 bottom-3 max-w-[calc(100%-1.5rem)] rounded-control px-3 py-2 text-sm text-fg sm:start-4 sm:bottom-4 sm:max-w-md sm:px-4">
{image.caption}
</figcaption>
)}
</div>
{captions && image.caption && captionPosition === "below" && <figcaption className="mt-3 text-sm text-muted">{image.caption}</figcaption>}
</figure>
))}
</Carousel>
{thumbs && (side ? <div className={cn("relative min-w-0 shrink-0", sideWidth[thumbnailSize])}>{thumbs}</div> : thumbs)}
</div>
);
}



