Lightbox

Full-screen image viewer with keyboard navigation and optional share callback.

API Reference

PropTypeDefault
imagesLightboxImage[]
openbooleanfalse
startIndexnumber0
onOpenChange(open: boolean) => void
onShare(id: string) => void

Preview

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} />

Basic Usage

import { Lightbox } from "@/components";

<Lightbox
  images={images}
  open={open}
  startIndex={0}
  onOpenChange={setOpen}
/>

Source

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>
  );
}