Components
ComponentsTier 4 · Composites
Lightbox
Universal fullscreen media viewer with zoom, pan, thumbnail gallery and global hook.
bunx --bun shadcn@latest add @woohl/lightboxPreview
Reference
Installs this item, its registry dependencies and the token layer.
bunx --bun shadcn@latest add @woohl/lightboxregistry/tier-4-composites/lightbox.tsx
"use client";
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type PointerEvent as ReactPointerEvent,
type MouseEvent as ReactMouseEvent,
type WheelEvent as ReactWheelEvent,
type ReactNode,
} from "react";
import { Dialog as DialogPrimitive } from "radix-ui";
import { cn } from "@/registry/tier-2-primitives/lib/utils";
import {
ChevronLeftGlyph,
ChevronRightGlyph,
CloseGlyph,
MinusGlyph,
PlusGlyph,
} from "@/registry/tier-2-primitives/glyphs";
/**
* Lightbox.
*
* Fullscreen media viewer with zoom, pan, thumbnail strip and keyboard navigation.
* Built on Radix Dialog for accessible focus trapping, escape key closure, and
* background scroll locking.
*
* TWO WAYS TO USE IT
* 1. Declarative `<Lightbox open={isOpen} images={images} onClose={...} />`
* for single-surface galleries.
* 2. Imperative `<LightboxProvider>` at app root + `useLightbox().openLightbox(...)`
* from any card, table, or deep child without prop drilling.
*/
const MAX_SCALE = 3;
const MIN_SCALE = 1;
export type LightboxImageItem = {
url: string;
alt?: string;
title?: string;
};
export type LightboxImageInput = string | LightboxImageItem;
export type OpenLightboxOptions = {
images: LightboxImageInput[];
activeIndex?: number;
title?: string;
};
export type LightboxContextValue = {
isOpen: boolean;
images: LightboxImageInput[];
activeIndex: number;
title?: string;
openLightbox: (options: OpenLightboxOptions) => void;
closeLightbox: () => void;
setActiveIndex: (index: number) => void;
};
export type LightboxProps = {
open: boolean;
onOpenChange?: (open: boolean) => void;
images: LightboxImageInput[];
activeIndex?: number;
onActiveIndexChange?: (index: number) => void;
onClose?: () => void;
title?: string;
className?: string;
};
export function normalizeLightboxImages(
images: LightboxImageInput[],
): LightboxImageItem[] {
return images.map((img) => (typeof img === "string" ? { url: img } : img));
}
const stopSurfaceEvents = {
onPointerDown: (event: ReactPointerEvent) => event.stopPropagation(),
onDoubleClick: (event: ReactMouseEvent) => event.stopPropagation(),
};
/* ------------------------------------------------------------ context & hook */
const LightboxContext = createContext<LightboxContextValue | null>(null);
export function useLightbox(): LightboxContextValue {
const context = useContext(LightboxContext);
if (!context) {
throw new Error("useLightbox must be used inside a <LightboxProvider>.");
}
return context;
}
export function LightboxProvider({ children }: { children: ReactNode }) {
const [isOpen, setIsOpen] = useState(false);
const [images, setImages] = useState<LightboxImageInput[]>([]);
const [activeIndex, setActiveIndex] = useState(0);
const [title, setTitle] = useState<string | undefined>(undefined);
const openLightbox = useCallback((options: OpenLightboxOptions) => {
if (!options.images || options.images.length === 0) return;
setImages(options.images);
setActiveIndex(
Math.max(
0,
Math.min(options.activeIndex ?? 0, options.images.length - 1),
),
);
setTitle(options.title);
setIsOpen(true);
}, []);
const closeLightbox = useCallback(() => {
setIsOpen(false);
}, []);
const value = useMemo<LightboxContextValue>(
() => ({
isOpen,
images,
activeIndex,
title,
openLightbox,
closeLightbox,
setActiveIndex,
}),
[isOpen, images, activeIndex, title, openLightbox, closeLightbox],
);
return (
<LightboxContext.Provider value={value}>
{children}
<Lightbox
open={isOpen}
onOpenChange={setIsOpen}
images={images}
activeIndex={activeIndex}
onActiveIndexChange={setActiveIndex}
onClose={closeLightbox}
title={title}
/>
</LightboxContext.Provider>
);
}
/* ------------------------------------------------------------ component */
export function Lightbox({
open,
onOpenChange,
images: rawImages,
activeIndex = 0,
onActiveIndexChange,
onClose,
title,
className,
}: LightboxProps) {
const images = useMemo(() => normalizeLightboxImages(rawImages), [rawImages]);
const [scale, setScale] = useState(MIN_SCALE);
const [offset, setOffset] = useState({ x: 0, y: 0 });
const [isDragging, setIsDragging] = useState(false);
const [prevIndex, setPrevIndex] = useState(activeIndex);
const dragState = useRef<{
pointerId: number;
startX: number;
startY: number;
} | null>(null);
if (prevIndex !== activeIndex) {
setPrevIndex(activeIndex);
setScale(MIN_SCALE);
setOffset({ x: 0, y: 0 });
}
const resetView = useCallback(() => {
setScale(MIN_SCALE);
setOffset({ x: 0, y: 0 });
}, []);
const isZoomed = scale > MIN_SCALE;
const activeImage = images[activeIndex] ?? images[0];
const handleDismiss = useCallback(() => {
onClose?.();
onOpenChange?.(false);
}, [onClose, onOpenChange]);
const toggleZoom = useCallback(() => {
if (isZoomed) {
resetView();
} else {
setScale(2);
}
}, [isZoomed, resetView]);
const zoomIn = useCallback(() => {
setScale((prev) => Math.min(MAX_SCALE, Number((prev + 0.5).toFixed(2))));
}, []);
const zoomOut = useCallback(() => {
setScale((prev) => {
const next = Math.max(MIN_SCALE, Number((prev - 0.5).toFixed(2)));
if (next === MIN_SCALE) setOffset({ x: 0, y: 0 });
return next;
});
}, []);
const handlePointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {
if (!isZoomed) return;
event.currentTarget.setPointerCapture(event.pointerId);
dragState.current = {
pointerId: event.pointerId,
startX: event.clientX - offset.x,
startY: event.clientY - offset.y,
};
setIsDragging(true);
};
const handlePointerMove = (event: ReactPointerEvent<HTMLDivElement>) => {
const drag = dragState.current;
if (drag?.pointerId !== event.pointerId) return;
setOffset({
x: event.clientX - drag.startX,
y: event.clientY - drag.startY,
});
};
const endDrag = (event: ReactPointerEvent<HTMLDivElement>) => {
if (dragState.current?.pointerId !== event.pointerId) return;
try {
event.currentTarget.releasePointerCapture(event.pointerId);
} catch {
// ignore
}
dragState.current = null;
setIsDragging(false);
};
const handleWheel = (event: ReactWheelEvent<HTMLDivElement>) => {
if (event.ctrlKey || event.metaKey) {
event.preventDefault();
const delta = -event.deltaY * 0.01;
setScale((prev) => {
const next = Math.min(MAX_SCALE, Math.max(MIN_SCALE, prev + delta));
if (next === MIN_SCALE) setOffset({ x: 0, y: 0 });
return Number(next.toFixed(2));
});
}
};
// Keyboard left / right arrows
useEffect(() => {
if (!open || images.length <= 1) return;
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "ArrowLeft") {
event.preventDefault();
onActiveIndexChange?.(Math.max(0, activeIndex - 1));
} else if (event.key === "ArrowRight") {
event.preventDefault();
onActiveIndexChange?.(Math.min(images.length - 1, activeIndex + 1));
}
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [open, activeIndex, images.length, onActiveIndexChange]);
const displayTitle = activeImage?.title ?? title;
const imageAlt =
activeImage?.alt ||
(displayTitle
? `${displayTitle} — view ${activeIndex + 1}`
: `Image ${activeIndex + 1} of ${images.length}`);
if (!open || images.length === 0) return null;
return (
<DialogPrimitive.Root
open={open}
onOpenChange={onOpenChange ?? ((val) => !val && handleDismiss())}
>
<DialogPrimitive.Portal>
<DialogPrimitive.Overlay
data-slot="lightbox-overlay"
className="z-modal animate-woohl-fade fixed inset-0 bg-[var(--scrim-overlay)] backdrop-blur-md"
/>
<DialogPrimitive.Content
data-slot="lightbox-content"
aria-describedby={undefined}
className={cn(
"z-modal fixed inset-0 flex flex-col select-none focus:outline-none",
className,
)}
>
<DialogPrimitive.Title className="sr-only">
{displayTitle || "Image viewer"}
</DialogPrimitive.Title>
{/* Header toolbar */}
<div className="text-text-inverse flex items-center justify-between p-4">
<div className="flex min-w-0 items-center gap-3">
{images.length > 1 && (
<span className="text-label rounded-pill text-text-inverse shrink-0 bg-white/10 px-2.5 py-1 font-medium tabular-nums">
{activeIndex + 1} / {images.length}
</span>
)}
{displayTitle && (
<h2 className="text-label text-text-inverse truncate font-medium">
{displayTitle}
</h2>
)}
</div>
<div className="flex shrink-0 items-center gap-2">
<button
type="button"
onClick={zoomOut}
disabled={scale <= MIN_SCALE}
aria-label="Zoom out"
className="rounded-pill flex size-10 items-center justify-center bg-white/10 text-white transition-colors hover:bg-white/20 disabled:pointer-events-none disabled:opacity-40"
>
<MinusGlyph size={18} />
</button>
<button
type="button"
onClick={zoomIn}
disabled={scale >= MAX_SCALE}
aria-label="Zoom in"
className="rounded-pill flex size-10 items-center justify-center bg-white/10 text-white transition-colors hover:bg-white/20 disabled:pointer-events-none disabled:opacity-40"
>
<PlusGlyph size={18} />
</button>
{isZoomed && (
<button
type="button"
onClick={resetView}
aria-label="Reset zoom"
className="rounded-pill text-label flex h-10 items-center bg-white/10 px-3 text-white transition-colors hover:bg-white/20"
>
Reset
</button>
)}
<button
type="button"
onClick={handleDismiss}
aria-label="Close image viewer"
className="rounded-pill flex size-10 items-center justify-center bg-white/10 text-white transition-colors hover:bg-white/20"
>
<CloseGlyph size={18} />
</button>
</div>
</div>
{/* Main viewport area */}
<div
className={cn(
"relative flex flex-1 items-center justify-center overflow-hidden p-4",
isZoomed
? "cursor-grab active:cursor-grabbing"
: "cursor-default",
)}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={endDrag}
onPointerCancel={endDrag}
onDoubleClick={toggleZoom}
onWheel={handleWheel}
>
<div
style={{
transform: `translate3d(${offset.x}px, ${offset.y}px, 0) scale(${scale})`,
transition: isDragging ? "none" : "transform 150ms ease-out",
}}
className="relative flex max-h-full max-w-full items-center justify-center will-change-transform"
>
{/* Native img avoids external image loader config requirements */}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={activeImage?.url}
alt={imageAlt}
className="pointer-events-none max-h-[80vh] max-w-[90vw] object-contain select-none"
draggable={false}
/>
</div>
{/* Prev / Next buttons */}
{images.length > 1 && (
<>
<button
type="button"
onClick={() => onActiveIndexChange?.(activeIndex - 1)}
{...stopSurfaceEvents}
disabled={activeIndex === 0}
aria-label="Previous image"
className="rounded-pill absolute top-1/2 left-6 flex size-14 -translate-y-1/2 items-center justify-center bg-white/10 text-white transition-colors hover:bg-white/20 disabled:pointer-events-none disabled:opacity-40"
>
<ChevronLeftGlyph size={28} />
</button>
<button
type="button"
onClick={() => onActiveIndexChange?.(activeIndex + 1)}
{...stopSurfaceEvents}
disabled={activeIndex === images.length - 1}
aria-label="Next image"
className="rounded-pill absolute top-1/2 right-6 flex size-14 -translate-y-1/2 items-center justify-center bg-white/10 text-white transition-colors hover:bg-white/20 disabled:pointer-events-none disabled:opacity-40"
>
<ChevronRightGlyph size={28} />
</button>
</>
)}
</div>
{/* Thumbnail strip */}
{images.length > 1 && (
<div className="flex justify-start gap-2 overflow-x-auto p-4 md:justify-center">
{images.map((img, index) => (
<button
key={`${img.url}-${index}`}
type="button"
onClick={() => onActiveIndexChange?.(index)}
aria-label={`View image ${index + 1}`}
aria-current={index === activeIndex}
className={cn(
"relative size-16 shrink-0 overflow-hidden rounded-md border-2 transition-all",
index === activeIndex
? "border-surface-accent scale-105"
: "border-transparent opacity-60 hover:opacity-100",
)}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={img.url}
alt={img.alt ?? ""}
className="size-full object-cover"
/>
</button>
))}
</div>
)}
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
</DialogPrimitive.Root>
);
}
export default Lightbox;
LightboxProps
| Prop | Type | Default |
|---|---|---|
images* | LightboxImageInput[] | — |
open* | boolean | — |
activeIndex | number | — |
className | string | — |
onActiveIndexChange | (index: number) => void | — |
onClose | () => void | — |
onOpenChange | (open: boolean) => void | — |
title | string | — |
Registry items
npm packages
- radix-ui