Image Lightbox
Zooms from the thumbnail; swipe between images.
Installation
CLI
npx shadcn@latest add https://hantui-theta.vercel.app/r/image-lightbox.jsonManual
Install the dependencies
npm install clsx lucide-react motion radix-ui tailwind-mergeAdd the shared helpers
Components use
cn()fromlib/utils.tsand the motion tokens below. See Installation if you have not set them up yet.lib/motion.tsimport type { Transition, Variants } from "motion/react"; /** Durations in seconds. */ export const duration = { instant: 0.1, quick: 0.18, base: 0.28, slow: 0.45, } as const; /** Cubic-bezier curves. */ export const ease = { /** Entering, UI response. */ out: [0.22, 1, 0.36, 1], /** Moving on screen. */ inOut: [0.65, 0, 0.35, 1], /** Sheets and the mobile menu. */ drawer: [0.32, 0.72, 0, 1], } as const; /** Everyday UI: presses, icon swaps, popovers and modals. Exits run at `exitFactor` of the enter. */ export const ui = { press: { scale: 0.96, duration: 0.12 }, icon: { type: "spring", duration: 0.3, bounce: 0 }, popover: { duration: 0.18, ease: ease.out }, modal: { duration: 0.24, ease: ease.out }, exitFactor: 0.6, } as const; /** Once-per-visit motion on the landing page: hero letters, the reel and the afterimage. */ export const kinetic = { heroLetter: { duration: 0.6, ease: ease.out }, /** Delay per letter, eased so the wave slows toward the end. */ heroStep: 0.035, reelSwap: { type: "spring", duration: 0.5, bounce: 0.1 }, /** Two afterimages: lag in seconds and opacity of each. */ ghost: { lag: [0.04, 0.09], opacity: [0.14, 0.06] }, } as const; /** Spring presets. */ export const spring = { snappy: { type: "spring", stiffness: 500, damping: 30 }, smooth: { type: "spring", stiffness: 260, damping: 26 }, bouncy: { type: "spring", stiffness: 300, damping: 15 }, } as const satisfies Record<string, Transition>; /** Stagger between siblings, in seconds (0.03–0.06). */ export const stagger = { tight: 0.03, base: 0.045, loose: 0.06, } as const; /** Blur + fade + y-offset. Falls back to a plain fade under reduced motion. */ export function enterVariants(reduceMotion?: boolean | null, y = 8): Variants { if (reduceMotion) { return { hidden: { opacity: 0 }, visible: { opacity: 1, transition: { duration: duration.base } }, exit: { opacity: 0, transition: { duration: duration.quick } }, }; } return { hidden: { opacity: 0, y, filter: "blur(4px)" }, visible: { opacity: 1, y: 0, filter: "blur(0px)", transition: { duration: duration.base, ease: ease.out }, }, exit: { opacity: 0, y: -y / 2, filter: "blur(4px)", transition: { duration: duration.quick, ease: ease.out }, }, }; } /** Scale 0.96 → 1 for popovers, menus and dialogs. */ export function overlayVariants(reduceMotion?: boolean | null): Variants { if (reduceMotion) { return { hidden: { opacity: 0 }, visible: { opacity: 1, transition: { duration: duration.quick } }, exit: { opacity: 0, transition: { duration: duration.instant } }, }; } return { hidden: { opacity: 0, scale: 0.96, filter: "blur(2px)" }, visible: { opacity: 1, scale: 1, filter: "blur(0px)", transition: { duration: duration.quick, ease: ease.out }, }, exit: { opacity: 0, scale: 0.96, filter: "blur(2px)", transition: { duration: duration.instant + 0.02, ease: ease.out }, }, }; } /** Parent variants that stagger their children in. */ export function staggerVariants(gap: number = stagger.base, delayChildren = 0): Variants { return { hidden: {}, visible: { transition: { staggerChildren: gap, delayChildren } }, exit: { transition: { staggerChildren: gap / 2, staggerDirection: -1 } }, }; }Copy the source
components/ui/image-lightbox/image-lightbox.tsx"use client"; import { useEffect, useId, useRef, useState, type ComponentProps, type KeyboardEvent } from "react"; import { AnimatePresence, motion, useReducedMotion, type PanInfo } from "motion/react"; import { Dialog } from "radix-ui"; import { ChevronLeft, ChevronRight, X } from "lucide-react"; import { cn } from "@/lib/utils"; import { duration, ease } from "@/lib/motion"; export interface LightboxImage { /** Image URL. */ src: string; /** Description of the image. */ alt: string; /** Intrinsic width; with height, keeps the aspect ratio while morphing. */ width: number; /** Intrinsic height. */ height: number; /** Optional caption under the open image. */ caption?: string; } export interface ImageLightboxProps extends Omit<ComponentProps<"div">, "children" | "onAnimationStart" | "onDrag" | "onDragStart" | "onDragEnd"> { /** Images, in order. */ images: LightboxImage[]; /** Controlled index of the open image, or null when closed. */ index?: number | null; /** Called when an image opens, changes or the lightbox closes. */ onIndexChange?: (index: number | null) => void; } const MORPH = { type: "spring", stiffness: 300, damping: 32 } as const; export function ImageLightbox({ images, index, onIndexChange, className, ...props }: ImageLightboxProps) { const reduceMotion = useReducedMotion(); const [internal, setInternal] = useState<number | null>(null); const current = index === undefined ? internal : index; // Shared-layout morphs only while opening and closing; swiping between images just slides. const [linked, setLinked] = useState(true); const [direction, setDirection] = useState(1); const id = useId(); const layout = reduceMotion ? { duration: 0 } : MORPH; const thumbs = useRef<(HTMLButtonElement | null)[]>([]); const lastIndex = useRef(0); useEffect(() => { if (current !== null) lastIndex.current = current; }, [current]); const set = (next: number | null) => { if (index === undefined) setInternal(next); onIndexChange?.(next); }; const open = (i: number) => { setLinked(true); set(i); }; // Link the current image to its thumbnail first, then close, so it morphs back to the right spot. const close = () => { setLinked(true); requestAnimationFrame(() => set(null)); }; const step = (delta: number) => { if (current === null || images.length < 2) return; setLinked(false); setDirection(delta); set((current + delta + images.length) % images.length); }; const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => { if (event.key === "ArrowRight") step(1); else if (event.key === "ArrowLeft") step(-1); else return; event.preventDefault(); }; const onDragEnd = (_: unknown, info: PanInfo) => { if (info.offset.x < -80 || info.velocity.x < -500) step(1); else if (info.offset.x > 80 || info.velocity.x > 500) step(-1); }; const image = current === null ? null : images[current]; const slide = { enter: (dir: number) => (reduceMotion ? { opacity: 0 } : { x: `${dir * 30}%`, opacity: 0 }), center: { x: "0%", opacity: 1 }, exit: (dir: number) => (reduceMotion ? { opacity: 0 } : { x: `${dir * -30}%`, opacity: 0 }), }; return ( <> <div className={cn("columns-2 gap-3 sm:columns-3", className)} {...props}> {images.map((img, i) => ( <button key={img.src} ref={(node) => { thumbs.current[i] = node; }} type="button" onClick={() => open(i)} aria-label={`Open image: ${img.alt}`} aria-haspopup="dialog" className="mb-3 block w-full cursor-zoom-in break-inside-avoid rounded-xl outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" > {current === i ? ( <span className="block w-full" style={{ aspectRatio: `${img.width} / ${img.height}` }} /> ) : ( <motion.img layoutId={`${id}-${i}`} transition={layout} src={img.src} alt="" width={img.width} height={img.height} draggable={false} className="block h-auto w-full object-cover" style={{ borderRadius: 12 }} /> )} </button> ))} </div> <Dialog.Root open={current !== null} onOpenChange={(next) => !next && close()}> <AnimatePresence> {image && current !== null ? ( <Dialog.Portal forceMount> <Dialog.Overlay asChild forceMount> <motion.div className="fixed inset-0 z-50 bg-background/95 backdrop-blur-sm" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: duration.base }} /> </Dialog.Overlay> <Dialog.Content forceMount aria-describedby={undefined} onKeyDown={onKeyDown} // Return focus to the thumbnail of the image that was showing, not the one first opened. onCloseAutoFocus={(event) => { event.preventDefault(); thumbs.current[lastIndex.current]?.focus({ preventScroll: true }); }} className="fixed inset-0 z-50 flex flex-col items-center justify-center gap-4 p-4 outline-none sm:p-6" > <Dialog.Title className="sr-only">{image.alt}</Dialog.Title> <div className="relative flex min-h-0 w-full flex-1 items-center justify-center"> <AnimatePresence initial={false} custom={direction} mode="popLayout"> <motion.img key={current} layoutId={linked ? `${id}-${current}` : undefined} src={image.src} alt={image.alt} width={image.width} height={image.height} draggable={false} custom={direction} variants={slide} initial="enter" animate="center" exit="exit" transition={{ layout, x: { type: "spring", stiffness: 320, damping: 34 }, opacity: { duration: duration.base, ease: ease.out } }} drag={images.length > 1 ? "x" : false} dragSnapToOrigin dragElastic={0.6} onDragEnd={onDragEnd} className="block h-auto max-h-full touch-pan-y object-contain shadow-xl select-none" style={{ aspectRatio: `${image.width} / ${image.height}`, width: `min(100%, calc((100dvh - 9rem) * ${image.width / image.height}), ${image.width}px)`, borderRadius: 12, }} /> </AnimatePresence> </div> <div className="flex w-full max-w-2xl items-center justify-between gap-4"> <button type="button" onClick={() => step(-1)} disabled={images.length < 2} aria-label="Previous image" className="inline-flex size-10 shrink-0 cursor-pointer items-center justify-center rounded-full border border-border bg-background text-foreground shadow-xs outline-none hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-40" > <ChevronLeft className="size-4" aria-hidden="true" /> </button> <div className="min-w-0 text-center"> {image.caption ? <p className="truncate text-sm text-foreground">{image.caption}</p> : null} <p className="text-xs text-muted-foreground tabular-nums" aria-live="polite"> {current + 1} / {images.length} </p> </div> <button type="button" onClick={() => step(1)} disabled={images.length < 2} aria-label="Next image" className="inline-flex size-10 shrink-0 cursor-pointer items-center justify-center rounded-full border border-border bg-background text-foreground shadow-xs outline-none hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-40" > <ChevronRight className="size-4" aria-hidden="true" /> </button> </div> <Dialog.Close aria-label="Close" className="absolute top-4 right-4 inline-flex size-10 cursor-pointer items-center justify-center rounded-full border border-border bg-background text-foreground shadow-xs outline-none hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring" > <X className="size-4" aria-hidden="true" /> </Dialog.Close> </Dialog.Content> </Dialog.Portal> ) : null} </AnimatePresence> </Dialog.Root> </> ); }
Usage
import { ImageLightbox } from "@/components/ui/image-lightbox/image-lightbox";
export function Gallery() {
return (
<ImageLightbox
images={[
{ src: "/photos/dusk.jpg", alt: "Dusk over the hills", width: 1200, height: 800, caption: "Dusk" },
{ src: "/photos/valley.jpg", alt: "Green valley", width: 1000, height: 1000 },
]}
/>
);
}API reference
| Prop | Type | Default | Description |
|---|---|---|---|
images* | LightboxImage[] | – | Images, in order. |
index | number | null | – | Controlled index of the open image, or null when closed. |
onIndexChange | (index: number | null) => void | – | Called when an image opens, changes or the lightbox closes. |
Also accepts every native <div> attribute, including ref and className.
- The open image zooms out of its thumbnail and, on close, back into the thumbnail of whichever image is showing, even after swiping.
- Thumbnails keep each image's own aspect ratio (a masonry column layout), so the zoom scales evenly instead of stretching. Pass width and height for every image.
- Swipe or drag sideways to change images; a short drag springs back.
Accessibility
- Thumbnails are buttons that open a real dialog: focus moves inside, Escape or the close button dismisses it, and focus returns to the thumbnail.
- Left and right arrow keys move between images, and the position (3 / 6) is announced.
- With reduced motion enabled, images appear and change with a fade instead of zooming and sliding.