Lightbox
Full-screen image viewer with keyboard navigation and optional share callback.
| Prop | Type | Default |
|---|---|---|
| images | LightboxImage[] | — |
| open | boolean | false |
| startIndex | number | 0 |
| onOpenChange | (open: boolean) => void | — |
| onShare | (id: string) => void | — |
import { Lightbox } from "@/components";
const images = [
{ src: "/photo-1.jpg", alt: "Photo 1" },
{ src: "/photo-2.jpg", alt: "Photo 2" },
];
<Lightbox images={images} open={open} onOpenChange={setOpen} />import { Lightbox } from "@/components";
<Lightbox
images={images}
open={open}
startIndex={0}
onOpenChange={setOpen}
/>src/components/Lightbox.tsx
"use client";
import { useEffect, useRef, useState } from "react";
import {
DialogRoot,
DialogBackdrop,
DialogContent,
DialogTitle,
Popover,
Image,
IconButton,
} from "@/ui";
import { CloseIcon, ShareIcon } from "@/ui/icons";
export interface LightboxImage {
id?: string;
src: string;
alt: string;
}
interface LightboxProps {
images: LightboxImage[];
open: boolean;
startIndex?: number;
onOpenChange: (open: boolean) => void;
onShare?: (id: string) => void;
}
export function Lightbox({
images,
open,
startIndex = 0,
onOpenChange,
onShare,
}: LightboxProps) {
const imageRefs = useRef<(HTMLDivElement | null)[]>([]);
const [copiedIndex, setCopiedIndex] = useState<number | null>(null);
useEffect(() => {
if (!open) return;
const id = setTimeout(() => {
imageRefs.current[startIndex]?.scrollIntoView({
behavior: "instant",
block: "start",
});
}, 0);
return () => clearTimeout(id);
}, [open, startIndex]);
function handleShare(i: number, id: string) {
onShare?.(id);
setCopiedIndex(i);
}
return (
<DialogRoot open={open} onOpenChange={onOpenChange}>
<DialogBackdrop />
<DialogContent>
<DialogTitle>Image viewer</DialogTitle>
<div className="absolute top-4 right-4 z-20 sm:top-6 sm:right-6">
<IconButton
variant="solid"
color="primary"
aria-label="Close lightbox"
onClick={() => onOpenChange(false)}
>
<CloseIcon />
</IconButton>
</div>
<div className="h-full overflow-y-auto">
<div className="flex flex-col gap-4 px-3 pt-16 pb-3 sm:gap-6 sm:px-6 sm:pt-20 sm:pb-8">
{images.map((img, i) => (
<div
key={`${img.src}-${i}`}
ref={(el) => {
imageRefs.current[i] = el;
}}
className="scroll-mt-4 sm:scroll-mt-8"
>
<div className="bg-root-100 relative w-full overflow-hidden rounded-xl sm:aspect-3/2">
<Image
src={img.src}
alt={img.alt}
width={1920}
height={1280}
className="h-auto w-full sm:absolute sm:inset-0 sm:h-full sm:object-contain"
sizes="(min-width: 640px) calc(100vw - 6rem), calc(100vw - 2.5rem)"
unoptimized
/>
{img.id && onShare && (
<div className="absolute right-3 bottom-3 z-10">
<Popover
open={copiedIndex === i}
onOpenChange={(isOpen) =>
!isOpen && setCopiedIndex(null)
}
content="Link copied!"
autoClose={2000}
>
<IconButton
variant="ghost"
color="root"
aria-label="Copy image link"
onClick={() => handleShare(i, img.id!)}
>
<ShareIcon />
</IconButton>
</Popover>
</div>
)}
</div>
</div>
))}
</div>
</div>
</DialogContent>
</DialogRoot>
);
}