Lightbox
Fullscreen image lightbox on Radix Dialog with gallery navigation, zoom and pan, swipe gestures, captions and a thumbnail strip.
Example
Installation
npx shadcn@latest add https://hirael.com/r/lightbox.jsonUsage
import {
Lightbox,
LightboxTrigger,
LightboxClose,
LightboxPortal,
LightboxOverlay,
LightboxContent,
LightboxThumbnails,
} from "@/components/lightbox"Paths assume the default @/ alias from your components.json. The example above shows the parts composed.
API
<Lightbox />
| Prop | Type | Default |
|---|---|---|
items* | LightboxItem[] | none |
open | boolean | none |
defaultOpen | boolean | false |
onOpenChange | ((open: boolean) => void) | none |
index | number | none |
defaultIndex | number | 0 |
onIndexChange | ((index: number) => void) | none |
loop | boolean | true |
children | React.ReactNode | none |
<LightboxTrigger />
+ native element props| Prop | Type | Default |
|---|---|---|
index | number | 0 |
<LightboxClose />
+ native element propsNo props of its own; forwards everything to the underlying element.
<LightboxPortal />
+ native element propsNo props of its own; forwards everything to the underlying element.
<LightboxOverlay />
+ native element propsNo props of its own; forwards everything to the underlying element.
<LightboxContent />
+ native element props| Prop | Type | Default |
|---|---|---|
labelsAccessible names for the built-in controls. | Partial<LightboxLabels> | none |
<LightboxThumbnails />
+ native element props| Prop | Type | Default |
|---|---|---|
getLabelAccessible name for a thumbnail whose item has no `alt`. | ((index: number) => string) | defaultThumbnailLabel |
Component source
'use client';
import * as React from 'react';
import * as DialogPrimitive from '@radix-ui/react-dialog';
import { ChevronLeft, ChevronRight, X, ZoomIn, ZoomOut } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useControllableState } from '@/registry/hirael/hooks/use-controllable-state';
export interface LightboxItem {
src: string;
alt?: string;
caption?: string;
thumbnail?: string;
}
interface LightboxContextValue {
items: LightboxItem[];
index: number;
zoomed: boolean;
setZoomed: (zoomed: boolean) => void;
goTo: (index: number) => void;
next: () => void;
prev: () => void;
canPrev: boolean;
canNext: boolean;
}
const LightboxContext = React.createContext<LightboxContextValue | null>(null);
const wrap = (i: number, count: number) => ((i % count) + count) % count;
const useLightbox = () => {
const ctx = React.useContext(LightboxContext);
if (!ctx) {
throw new Error('Lightbox compound components must be used inside <Lightbox>');
}
return ctx;
};
export interface LightboxProps {
items: LightboxItem[];
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
index?: number;
defaultIndex?: number;
onIndexChange?: (index: number) => void;
loop?: boolean;
children?: React.ReactNode;
}
const Lightbox = ({
items,
open: openProp,
defaultOpen = false,
onOpenChange,
index: indexProp,
defaultIndex = 0,
onIndexChange,
loop = true,
children,
}: LightboxProps) => {
const [open, setOpen] = useControllableState({
prop: openProp,
defaultProp: defaultOpen,
onChange: onOpenChange,
});
const count = items.length;
const [internalIndex, setInternalIndex] = React.useState(defaultIndex);
const index = Math.min(Math.max(indexProp ?? internalIndex, 0), Math.max(count - 1, 0));
const setIndex = React.useCallback(
(next: number) => {
if (indexProp === undefined) setInternalIndex(next);
onIndexChange?.(next);
},
[indexProp, onIndexChange],
);
const [zoomed, setZoomed] = React.useState(false);
const goTo = React.useCallback(
(i: number) => {
if (count === 0) return;
setIndex(loop ? wrap(i, count) : Math.min(Math.max(i, 0), count - 1));
setZoomed(false);
},
[count, loop, setIndex],
);
const next = React.useCallback(() => goTo(index + 1), [goTo, index]);
const prev = React.useCallback(() => goTo(index - 1), [goTo, index]);
const canPrev = loop ? count > 1 : index > 0;
const canNext = loop ? count > 1 : index < count - 1;
React.useEffect(() => {
if (!open || count === 0) return;
for (const i of [index - 1, index + 1]) {
const item = items[loop ? wrap(i, count) : i];
if (item) {
const img = new window.Image();
img.src = item.src;
}
}
}, [open, index, items, count, loop]);
const value = React.useMemo<LightboxContextValue>(
() => ({ items, index, zoomed, setZoomed, goTo, next, prev, canPrev, canNext }),
[items, index, zoomed, goTo, next, prev, canPrev, canNext],
);
return (
<LightboxContext.Provider value={value}>
<DialogPrimitive.Root data-slot="lightbox" open={open} onOpenChange={setOpen}>
{children}
</DialogPrimitive.Root>
</LightboxContext.Provider>
);
};
interface LightboxTriggerProps extends React.ComponentProps<typeof DialogPrimitive.Trigger> {
index?: number;
}
const LightboxTrigger = ({ index = 0, onClick, ...props }: LightboxTriggerProps) => {
const { goTo } = useLightbox();
return (
<DialogPrimitive.Trigger
data-slot="lightbox-trigger"
onClick={(event) => {
goTo(index);
onClick?.(event);
}}
{...props}
/>
);
};
const LightboxClose = (props: React.ComponentProps<typeof DialogPrimitive.Close>) => {
return <DialogPrimitive.Close data-slot="lightbox-close" {...props} />;
};
const LightboxPortalContext = React.createContext(false);
/** Wrap `LightboxContent` in this to compose your own overlay; the content then skips its built-in portal and overlay. */
const LightboxPortal = (props: React.ComponentProps<typeof DialogPrimitive.Portal>) => {
return (
<LightboxPortalContext.Provider value>
<DialogPrimitive.Portal data-slot="lightbox-portal" {...props} />
</LightboxPortalContext.Provider>
);
};
const LightboxOverlay = ({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Overlay>) => {
return (
<DialogPrimitive.Overlay
data-slot="lightbox-overlay"
className={cn(
'fixed inset-0 z-50 bg-black/90 backdrop-blur-sm',
'data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0',
'duration-200 ease-out motion-reduce:animate-none',
className,
)}
{...props}
/>
);
};
const chromeButtonClass =
'inline-flex items-center justify-center rounded-md bg-background/60 p-2 text-foreground backdrop-blur-sm transition-colors hover:bg-background/80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring motion-reduce:transition-none';
export interface LightboxLabels {
zoomIn: string;
zoomOut: string;
close: string;
previous: string;
next: string;
/** Dialog title for an item without `alt`. */
image: (index: number, count: number) => string;
}
const defaultLabels: LightboxLabels = {
zoomIn: 'Zoom in',
zoomOut: 'Zoom out',
close: 'Close',
previous: 'Previous image',
next: 'Next image',
image: (index, count) => `Image ${index + 1} of ${count}`,
};
const ZOOM_SCALE = 2;
const isEditableTarget = (target: EventTarget) => {
if (!(target instanceof HTMLElement)) return false;
return target.isContentEditable || target.closest('input, textarea, select') !== null;
};
export interface LightboxContentProps extends React.ComponentProps<typeof DialogPrimitive.Content> {
/** Accessible names for the built-in controls. */
labels?: Partial<LightboxLabels>;
}
const LightboxContent = ({ labels, className, children, onKeyDown, ...props }: LightboxContentProps) => {
const { items, index, zoomed, setZoomed, goTo, next, prev, canPrev, canNext } = useLightbox();
const inPortal = React.useContext(LightboxPortalContext);
const item = items[index];
const text = React.useMemo(() => ({ ...defaultLabels, ...labels }), [labels]);
const viewportRef = React.useRef<HTMLDivElement | null>(null);
const imageRef = React.useRef<HTMLImageElement | null>(null);
const [pan, setPan] = React.useState({ x: 0, y: 0 });
const [dragging, setDragging] = React.useState(false);
const dragRef = React.useRef<{
pointerId: number;
startX: number;
startY: number;
panX: number;
panY: number;
moved: boolean;
} | null>(null);
const panKey = `${index}:${zoomed}`;
const [lastPanKey, setLastPanKey] = React.useState(panKey);
if (lastPanKey !== panKey) {
setLastPanKey(panKey);
setPan({ x: 0, y: 0 });
}
function isRtl(el: HTMLElement) {
return getComputedStyle(el).direction === 'rtl';
}
function clampPan(x: number, y: number) {
const viewport = viewportRef.current;
const image = imageRef.current;
if (!viewport || !image) return { x, y };
const maxX = Math.max(0, (image.offsetWidth * ZOOM_SCALE - viewport.clientWidth) / 2);
const maxY = Math.max(0, (image.offsetHeight * ZOOM_SCALE - viewport.clientHeight) / 2);
return { x: Math.min(maxX, Math.max(-maxX, x)), y: Math.min(maxY, Math.max(-maxY, y)) };
}
function handleKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
onKeyDown?.(event);
if (event.defaultPrevented || isEditableTarget(event.target)) return;
const rtl = isRtl(event.currentTarget);
const toggleZoom = () => setZoomed(!zoomed);
const action = (
{
ArrowRight: rtl ? prev : next,
ArrowLeft: rtl ? next : prev,
Home: () => goTo(0),
End: () => goTo(items.length - 1),
z: toggleZoom,
Z: toggleZoom,
} as Record<string, () => void>
)[event.key];
if (!action) return;
event.preventDefault();
action();
}
function handlePointerDown(event: React.PointerEvent<HTMLDivElement>) {
if (event.pointerType === 'mouse' && event.button !== 0) return;
event.currentTarget.setPointerCapture(event.pointerId);
dragRef.current = {
pointerId: event.pointerId,
startX: event.clientX,
startY: event.clientY,
panX: pan.x,
panY: pan.y,
moved: false,
};
setDragging(true);
}
function handlePointerMove(event: React.PointerEvent<HTMLDivElement>) {
const drag = dragRef.current;
if (!drag || drag.pointerId !== event.pointerId) return;
const dx = event.clientX - drag.startX;
const dy = event.clientY - drag.startY;
if (Math.abs(dx) > 4 || Math.abs(dy) > 4) drag.moved = true;
if (zoomed) setPan(clampPan(drag.panX + dx, drag.panY + dy));
}
function handlePointerUp(event: React.PointerEvent<HTMLDivElement>) {
const drag = dragRef.current;
if (!drag || drag.pointerId !== event.pointerId) return;
dragRef.current = null;
setDragging(false);
const dx = event.clientX - drag.startX;
if (!zoomed && Math.abs(dx) > 64) {
if (dx < 0 !== isRtl(event.currentTarget)) next();
else prev();
return;
}
const target = event.target as HTMLElement;
if (!drag.moved && target.closest('[data-slot=lightbox-image]')) {
setZoomed(!zoomed);
}
}
function handlePointerCancel(event: React.PointerEvent<HTMLDivElement>) {
const drag = dragRef.current;
if (!drag || drag.pointerId !== event.pointerId) return;
dragRef.current = null;
setDragging(false);
if (zoomed) setPan({ x: drag.panX, y: drag.panY });
}
const content = (
<DialogPrimitive.Content
data-slot="lightbox-content"
aria-describedby={undefined}
onKeyDown={handleKeyDown}
className={cn(
'fixed inset-0 z-50 flex flex-col outline-none',
'data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
'duration-200 ease-out motion-reduce:animate-none',
className,
)}
{...props}
>
<DialogPrimitive.Title className="sr-only">{item?.alt || text.image(index, items.length)}</DialogPrimitive.Title>
<div
ref={viewportRef}
data-slot="lightbox-viewport"
className="flex min-h-0 w-full flex-1 touch-none items-center justify-center overflow-hidden p-14 select-none"
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerCancel}
>
{item && ( // eslint-disable-next-line @next/next/no-img-element
<img
ref={imageRef}
data-slot="lightbox-image"
src={item.src}
alt={item.alt ?? ''}
draggable={false}
className={cn(
'max-h-full max-w-full object-contain transition-transform duration-200 motion-reduce:transition-none',
zoomed ? 'cursor-grab' : 'cursor-zoom-in',
dragging && zoomed && 'cursor-grabbing transition-none',
)}
style={{
transform: zoomed ? `translate(${pan.x}px, ${pan.y}px) scale(${ZOOM_SCALE})` : undefined,
}}
/>
)}
</div>
{item?.caption && (
<p
data-slot="lightbox-caption"
className="mx-auto mb-3 max-w-prose rounded-md bg-background/60 px-3 py-1.5 text-center text-sm text-foreground backdrop-blur-sm"
>
{item.caption}
</p>
)}
{children}
<span
data-slot="lightbox-counter"
className="absolute start-3 top-3 rounded-full bg-background/60 px-2.5 py-1 text-xs text-foreground tabular-nums backdrop-blur-sm"
>
{index + 1} / {items.length}
</span>
<div className="absolute end-3 top-3 flex items-center gap-2">
<button
type="button"
data-slot="lightbox-zoom"
aria-label={zoomed ? text.zoomOut : text.zoomIn}
aria-pressed={zoomed}
onClick={() => setZoomed(!zoomed)}
className={chromeButtonClass}
>
{zoomed ? <ZoomOut className="size-4" /> : <ZoomIn className="size-4" />}
</button>
<DialogPrimitive.Close data-slot="lightbox-close" aria-label={text.close} className={chromeButtonClass}>
<X className="size-4" />
</DialogPrimitive.Close>
</div>
{canPrev && (
<button
type="button"
data-slot="lightbox-prev"
aria-label={text.previous}
onClick={prev}
className={cn(chromeButtonClass, 'absolute start-3 top-1/2 -translate-y-1/2')}
>
<ChevronLeft className="size-5 rtl:rotate-180" />
</button>
)}
{canNext && (
<button
type="button"
data-slot="lightbox-next"
aria-label={text.next}
onClick={next}
className={cn(chromeButtonClass, 'absolute end-3 top-1/2 -translate-y-1/2')}
>
<ChevronRight className="size-5 rtl:rotate-180" />
</button>
)}
</DialogPrimitive.Content>
);
if (inPortal) return content;
return (
<LightboxPortal>
<LightboxOverlay />
{content}
</LightboxPortal>
);
};
export interface LightboxThumbnailsProps extends React.ComponentProps<'div'> {
/** Accessible name for a thumbnail whose item has no `alt`. */
getLabel?: (index: number) => string;
}
const defaultThumbnailLabel = (index: number) => `Go to image ${index + 1}`;
const LightboxThumbnails = ({ getLabel = defaultThumbnailLabel, className, ...props }: LightboxThumbnailsProps) => {
const { items, index, goTo } = useLightbox();
const refs = React.useRef<(HTMLButtonElement | null)[]>([]);
React.useEffect(() => {
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
refs.current[index]?.scrollIntoView({ block: 'nearest', inline: 'nearest', behavior: reduce ? 'auto' : 'smooth' });
}, [index]);
return (
<div
data-slot="lightbox-thumbnails"
className={cn('mx-auto mb-3 flex max-w-full gap-2 overflow-x-auto p-1', className)}
{...props}
>
{items.map((item, i) => (
<button
key={`${item.src}-${i}`}
type="button"
ref={(el) => {
refs.current[i] = el;
}}
data-slot="lightbox-thumbnail"
data-active={i === index || undefined}
aria-label={item.alt ?? getLabel(i)}
aria-current={i === index ? 'true' : undefined}
onClick={() => goTo(i)}
className={cn(
'size-14 shrink-0 overflow-hidden rounded-md opacity-60 transition-opacity hover:opacity-100 focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none motion-reduce:transition-none',
i === index && 'opacity-100 ring-2 ring-ring',
)}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={item.thumbnail ?? item.src} alt="" draggable={false} className="size-full object-cover" />
</button>
))}
</div>
);
};
export {
Lightbox,
LightboxTrigger,
LightboxClose,
LightboxPortal,
LightboxOverlay,
LightboxContent,
LightboxThumbnails,
};
Dependencies
shadcn registry
use-controllable-state
npm
@radix-ui/react-dialoglucide-react