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 propsNo props of its own; forwards everything to the underlying element.
<LightboxThumbnails />
+ native element propsNo props of its own; forwards everything to the underlying element.
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';
export interface LightboxItem {
src: string;
alt?: string;
caption?: string;
thumbnail?: string;
}
interface LightboxContextValue {
items: LightboxItem[];
index: number;
loop: boolean;
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 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 [internalOpen, setInternalOpen] = React.useState(defaultOpen);
const open = openProp ?? internalOpen;
const setOpen = React.useCallback(
(next: boolean) => {
if (openProp === undefined) setInternalOpen(next);
onOpenChange?.(next);
},
[openProp, onOpenChange],
);
const [internalIndex, setInternalIndex] = React.useState(defaultIndex);
const index = indexProp ?? internalIndex;
const setIndex = React.useCallback(
(next: number) => {
if (indexProp === undefined) setInternalIndex(next);
onIndexChange?.(next);
},
[indexProp, onIndexChange],
);
const [zoomed, setZoomed] = React.useState(false);
const count = items.length;
const goTo = React.useCallback(
(i: number) => {
if (count === 0) return;
const next = loop ? ((i % count) + count) % count : Math.min(Math.max(i, 0), count - 1);
setIndex(next);
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 ? ((i % count) + count) % 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,
loop,
zoomed,
setZoomed,
goTo,
next,
prev,
canPrev,
canNext,
}),
[items, index, loop, 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 LightboxPortal = (props: React.ComponentProps<typeof DialogPrimitive.Portal>) => {
return <DialogPrimitive.Portal data-slot="lightbox-portal" {...props} />;
};
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=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 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-black/50 p-2 text-white backdrop-blur-sm transition-colors hover:bg-black/70 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring motion-reduce:transition-none';
const LightboxContent = ({
className,
children,
onKeyDown,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content>) => {
const { items, index, zoomed, setZoomed, goTo, next, prev, canPrev, canNext } = useLightbox();
const item = items[index];
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);
React.useEffect(() => {
setPan({ x: 0, y: 0 });
}, [index, zoomed]);
function isRtl(el: HTMLElement) {
return getComputedStyle(el).direction === 'rtl';
}
function handleKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
onKeyDown?.(event);
if (event.defaultPrevented) return;
const rtl = isRtl(event.currentTarget);
switch (event.key) {
case 'ArrowRight':
event.preventDefault();
if (rtl) prev();
else next();
break;
case 'ArrowLeft':
event.preventDefault();
if (rtl) next();
else prev();
break;
case 'Home':
event.preventDefault();
goTo(0);
break;
case 'End':
event.preventDefault();
goTo(items.length - 1);
break;
case 'z':
case 'Z':
event.preventDefault();
setZoomed(!zoomed);
break;
}
}
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({ x: drag.panX + dx, y: 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) {
const rtl = isRtl(event.currentTarget);
if (dx < 0) {
if (rtl) prev();
else next();
} else {
if (rtl) 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 });
}
return (
<LightboxPortal>
<LightboxOverlay />
<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=open]:animate-in data-[state=closed]:animate-out data-[state=open]:fade-in-0 data-[state=closed]:fade-out-0 data-[state=open]:zoom-in-95',
'duration-200 ease-out motion-reduce:animate-none',
className,
)}
{...props}
>
<DialogPrimitive.Title className="sr-only">
{item?.alt || `Image ${index + 1} of ${items.length}`}
</DialogPrimitive.Title>
<div
data-slot="lightbox-viewport"
className="flex min-h-0 w-full flex-1 touch-none select-none items-center justify-center overflow-hidden px-14 py-14"
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerCancel}
>
{item ? (
// eslint-disable-next-line @next/next/no-img-element
<img
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(2)` : undefined,
}}
/>
) : null}
</div>
{item?.caption ? (
<p
data-slot="lightbox-caption"
className="mx-auto mb-3 max-w-prose rounded-md bg-black/50 px-3 py-1.5 text-center text-sm text-white backdrop-blur-sm"
>
{item.caption}
</p>
) : null}
{children}
<span
data-slot="lightbox-counter"
className="absolute start-3 top-3 rounded-full bg-black/50 px-2.5 py-1 font-mono text-xs tabular-nums text-white 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 ? 'Zoom out' : 'Zoom in'}
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="Close" className={chromeButtonClass}>
<X className="size-4" />
</DialogPrimitive.Close>
</div>
{canPrev ? (
<button
type="button"
data-slot="lightbox-prev"
aria-label="Previous image"
onClick={prev}
className={cn(chromeButtonClass, 'absolute start-3 top-1/2 -translate-y-1/2')}
>
<ChevronLeft className="size-5 rtl:rotate-180" />
</button>
) : null}
{canNext ? (
<button
type="button"
data-slot="lightbox-next"
aria-label="Next image"
onClick={next}
className={cn(chromeButtonClass, 'absolute end-3 top-1/2 -translate-y-1/2')}
>
<ChevronRight className="size-5 rtl:rotate-180" />
</button>
) : null}
</DialogPrimitive.Content>
</LightboxPortal>
);
};
const LightboxThumbnails = ({ className, ...props }: React.ComponentProps<'div'>) => {
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 ?? `Go to image ${i + 1}`}
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:outline-none focus-visible:ring-2 focus-visible:ring-ring 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
npm
@radix-ui/react-dialoglucide-react