Skip to content

Image Lightbox

Zooms from the thumbnail; swipe between images.

Installation

CLI

npx shadcn@latest add https://hantui-theta.vercel.app/r/image-lightbox.json

Manual

  1. Install the dependencies

    npm install clsx lucide-react motion radix-ui tailwind-merge
  2. Add the shared helpers

    Components use cn() from lib/utils.ts and the motion tokens below. See Installation if you have not set them up yet.

    lib/motion.ts
    import type { Transition, Variants } from "motion/react";
    
    /** Durations in seconds. */
    export const duration = {
      instant: 0.1,
      quick: 0.18,
      base: 0.28,
      slow: 0.45,
    } as const;
    
    /** Cubic-bezier curves. */
    export const ease = {
      /** Entering, UI response. */
      out: [0.22, 1, 0.36, 1],
      /** Moving on screen. */
      inOut: [0.65, 0, 0.35, 1],
      /** Sheets and the mobile menu. */
      drawer: [0.32, 0.72, 0, 1],
    } as const;
    
    /** Everyday UI: presses, icon swaps, popovers and modals. Exits run at `exitFactor` of the enter. */
    export const ui = {
      press: { scale: 0.96, duration: 0.12 },
      icon: { type: "spring", duration: 0.3, bounce: 0 },
      popover: { duration: 0.18, ease: ease.out },
      modal: { duration: 0.24, ease: ease.out },
      exitFactor: 0.6,
    } as const;
    
    /** Once-per-visit motion on the landing page: hero letters, the reel and the afterimage. */
    export const kinetic = {
      heroLetter: { duration: 0.6, ease: ease.out },
      /** Delay per letter, eased so the wave slows toward the end. */
      heroStep: 0.035,
      reelSwap: { type: "spring", duration: 0.5, bounce: 0.1 },
      /** Two afterimages: lag in seconds and opacity of each. */
      ghost: { lag: [0.04, 0.09], opacity: [0.14, 0.06] },
    } as const;
    
    /** Spring presets. */
    export const spring = {
      snappy: { type: "spring", stiffness: 500, damping: 30 },
      smooth: { type: "spring", stiffness: 260, damping: 26 },
      bouncy: { type: "spring", stiffness: 300, damping: 15 },
    } as const satisfies Record<string, Transition>;
    
    /** Stagger between siblings, in seconds (0.03–0.06). */
    export const stagger = {
      tight: 0.03,
      base: 0.045,
      loose: 0.06,
    } as const;
    
    /** Blur + fade + y-offset. Falls back to a plain fade under reduced motion. */
    export function enterVariants(reduceMotion?: boolean | null, y = 8): Variants {
      if (reduceMotion) {
        return {
          hidden: { opacity: 0 },
          visible: { opacity: 1, transition: { duration: duration.base } },
          exit: { opacity: 0, transition: { duration: duration.quick } },
        };
      }
      return {
        hidden: { opacity: 0, y, filter: "blur(4px)" },
        visible: {
          opacity: 1,
          y: 0,
          filter: "blur(0px)",
          transition: { duration: duration.base, ease: ease.out },
        },
        exit: {
          opacity: 0,
          y: -y / 2,
          filter: "blur(4px)",
          transition: { duration: duration.quick, ease: ease.out },
        },
      };
    }
    
    /** Scale 0.96 → 1 for popovers, menus and dialogs. */
    export function overlayVariants(reduceMotion?: boolean | null): Variants {
      if (reduceMotion) {
        return {
          hidden: { opacity: 0 },
          visible: { opacity: 1, transition: { duration: duration.quick } },
          exit: { opacity: 0, transition: { duration: duration.instant } },
        };
      }
      return {
        hidden: { opacity: 0, scale: 0.96, filter: "blur(2px)" },
        visible: {
          opacity: 1,
          scale: 1,
          filter: "blur(0px)",
          transition: { duration: duration.quick, ease: ease.out },
        },
        exit: {
          opacity: 0,
          scale: 0.96,
          filter: "blur(2px)",
          transition: { duration: duration.instant + 0.02, ease: ease.out },
        },
      };
    }
    
    /** Parent variants that stagger their children in. */
    export function staggerVariants(gap: number = stagger.base, delayChildren = 0): Variants {
      return {
        hidden: {},
        visible: { transition: { staggerChildren: gap, delayChildren } },
        exit: { transition: { staggerChildren: gap / 2, staggerDirection: -1 } },
      };
    }
  3. Copy the source

    components/ui/image-lightbox/image-lightbox.tsx
    "use client";
    
    import { useEffect, useId, useRef, useState, type ComponentProps, type KeyboardEvent } from "react";
    import { AnimatePresence, motion, useReducedMotion, type PanInfo } from "motion/react";
    import { Dialog } from "radix-ui";
    import { ChevronLeft, ChevronRight, X } from "lucide-react";
    import { cn } from "@/lib/utils";
    import { duration, ease } from "@/lib/motion";
    
    export interface LightboxImage {
      /** Image URL. */
      src: string;
      /** Description of the image. */
      alt: string;
      /** Intrinsic width; with height, keeps the aspect ratio while morphing. */
      width: number;
      /** Intrinsic height. */
      height: number;
      /** Optional caption under the open image. */
      caption?: string;
    }
    
    export interface ImageLightboxProps
      extends Omit<ComponentProps<"div">, "children" | "onAnimationStart" | "onDrag" | "onDragStart" | "onDragEnd"> {
      /** Images, in order. */
      images: LightboxImage[];
      /** Controlled index of the open image, or null when closed. */
      index?: number | null;
      /** Called when an image opens, changes or the lightbox closes. */
      onIndexChange?: (index: number | null) => void;
    }
    
    const MORPH = { type: "spring", stiffness: 300, damping: 32 } as const;
    
    export function ImageLightbox({ images, index, onIndexChange, className, ...props }: ImageLightboxProps) {
      const reduceMotion = useReducedMotion();
      const [internal, setInternal] = useState<number | null>(null);
      const current = index === undefined ? internal : index;
      // Shared-layout morphs only while opening and closing; swiping between images just slides.
      const [linked, setLinked] = useState(true);
      const [direction, setDirection] = useState(1);
      const id = useId();
      const layout = reduceMotion ? { duration: 0 } : MORPH;
      const thumbs = useRef<(HTMLButtonElement | null)[]>([]);
      const lastIndex = useRef(0);
      useEffect(() => {
        if (current !== null) lastIndex.current = current;
      }, [current]);
    
      const set = (next: number | null) => {
        if (index === undefined) setInternal(next);
        onIndexChange?.(next);
      };
      const open = (i: number) => {
        setLinked(true);
        set(i);
      };
      // Link the current image to its thumbnail first, then close, so it morphs back to the right spot.
      const close = () => {
        setLinked(true);
        requestAnimationFrame(() => set(null));
      };
      const step = (delta: number) => {
        if (current === null || images.length < 2) return;
        setLinked(false);
        setDirection(delta);
        set((current + delta + images.length) % images.length);
      };
    
      const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
        if (event.key === "ArrowRight") step(1);
        else if (event.key === "ArrowLeft") step(-1);
        else return;
        event.preventDefault();
      };
    
      const onDragEnd = (_: unknown, info: PanInfo) => {
        if (info.offset.x < -80 || info.velocity.x < -500) step(1);
        else if (info.offset.x > 80 || info.velocity.x > 500) step(-1);
      };
    
      const image = current === null ? null : images[current];
      const slide = {
        enter: (dir: number) => (reduceMotion ? { opacity: 0 } : { x: `${dir * 30}%`, opacity: 0 }),
        center: { x: "0%", opacity: 1 },
        exit: (dir: number) => (reduceMotion ? { opacity: 0 } : { x: `${dir * -30}%`, opacity: 0 }),
      };
    
      return (
        <>
          <div className={cn("columns-2 gap-3 sm:columns-3", className)} {...props}>
            {images.map((img, i) => (
              <button
                key={img.src}
                ref={(node) => {
                  thumbs.current[i] = node;
                }}
                type="button"
                onClick={() => open(i)}
                aria-label={`Open image: ${img.alt}`}
                aria-haspopup="dialog"
                className="mb-3 block w-full cursor-zoom-in break-inside-avoid rounded-xl outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
              >
                {current === i ? (
                  <span className="block w-full" style={{ aspectRatio: `${img.width} / ${img.height}` }} />
                ) : (
                  <motion.img
                    layoutId={`${id}-${i}`}
                    transition={layout}
                    src={img.src}
                    alt=""
                    width={img.width}
                    height={img.height}
                    draggable={false}
                    className="block h-auto w-full object-cover"
                    style={{ borderRadius: 12 }}
                  />
                )}
              </button>
            ))}
          </div>
    
          <Dialog.Root open={current !== null} onOpenChange={(next) => !next && close()}>
            <AnimatePresence>
              {image && current !== null ? (
                <Dialog.Portal forceMount>
                  <Dialog.Overlay asChild forceMount>
                    <motion.div
                      className="fixed inset-0 z-50 bg-background/95 backdrop-blur-sm"
                      initial={{ opacity: 0 }}
                      animate={{ opacity: 1 }}
                      exit={{ opacity: 0 }}
                      transition={{ duration: duration.base }}
                    />
                  </Dialog.Overlay>
                  <Dialog.Content
                    forceMount
                    aria-describedby={undefined}
                    onKeyDown={onKeyDown}
                    // Return focus to the thumbnail of the image that was showing, not the one first opened.
                    onCloseAutoFocus={(event) => {
                      event.preventDefault();
                      thumbs.current[lastIndex.current]?.focus({ preventScroll: true });
                    }}
                    className="fixed inset-0 z-50 flex flex-col items-center justify-center gap-4 p-4 outline-none sm:p-6"
                  >
                    <Dialog.Title className="sr-only">{image.alt}</Dialog.Title>
                    <div className="relative flex min-h-0 w-full flex-1 items-center justify-center">
                      <AnimatePresence initial={false} custom={direction} mode="popLayout">
                        <motion.img
                          key={current}
                          layoutId={linked ? `${id}-${current}` : undefined}
                          src={image.src}
                          alt={image.alt}
                          width={image.width}
                          height={image.height}
                          draggable={false}
                          custom={direction}
                          variants={slide}
                          initial="enter"
                          animate="center"
                          exit="exit"
                          transition={{ layout, x: { type: "spring", stiffness: 320, damping: 34 }, opacity: { duration: duration.base, ease: ease.out } }}
                          drag={images.length > 1 ? "x" : false}
                          dragSnapToOrigin
                          dragElastic={0.6}
                          onDragEnd={onDragEnd}
                          className="block h-auto max-h-full touch-pan-y object-contain shadow-xl select-none"
                          style={{
                            aspectRatio: `${image.width} / ${image.height}`,
                            width: `min(100%, calc((100dvh - 9rem) * ${image.width / image.height}), ${image.width}px)`,
                            borderRadius: 12,
                          }}
                        />
                      </AnimatePresence>
                    </div>
                    <div className="flex w-full max-w-2xl items-center justify-between gap-4">
                      <button
                        type="button"
                        onClick={() => step(-1)}
                        disabled={images.length < 2}
                        aria-label="Previous image"
                        className="inline-flex size-10 shrink-0 cursor-pointer items-center justify-center rounded-full border border-border bg-background text-foreground shadow-xs outline-none hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-40"
                      >
                        <ChevronLeft className="size-4" aria-hidden="true" />
                      </button>
                      <div className="min-w-0 text-center">
                        {image.caption ? <p className="truncate text-sm text-foreground">{image.caption}</p> : null}
                        <p className="text-xs text-muted-foreground tabular-nums" aria-live="polite">
                          {current + 1} / {images.length}
                        </p>
                      </div>
                      <button
                        type="button"
                        onClick={() => step(1)}
                        disabled={images.length < 2}
                        aria-label="Next image"
                        className="inline-flex size-10 shrink-0 cursor-pointer items-center justify-center rounded-full border border-border bg-background text-foreground shadow-xs outline-none hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-40"
                      >
                        <ChevronRight className="size-4" aria-hidden="true" />
                      </button>
                    </div>
                    <Dialog.Close
                      aria-label="Close"
                      className="absolute top-4 right-4 inline-flex size-10 cursor-pointer items-center justify-center rounded-full border border-border bg-background text-foreground shadow-xs outline-none hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring"
                    >
                      <X className="size-4" aria-hidden="true" />
                    </Dialog.Close>
                  </Dialog.Content>
                </Dialog.Portal>
              ) : null}
            </AnimatePresence>
          </Dialog.Root>
        </>
      );
    }

Usage

import { ImageLightbox } from "@/components/ui/image-lightbox/image-lightbox";

export function Gallery() {
  return (
    <ImageLightbox
      images={[
        { src: "/photos/dusk.jpg", alt: "Dusk over the hills", width: 1200, height: 800, caption: "Dusk" },
        { src: "/photos/valley.jpg", alt: "Green valley", width: 1000, height: 1000 },
      ]}
    />
  );
}

API reference

PropTypeDefaultDescription
images*LightboxImage[]–Images, in order.
indexnumber | null–Controlled index of the open image, or null when closed.
onIndexChange(index: number | null) => void–Called when an image opens, changes or the lightbox closes.

Also accepts every native <div> attribute, including ref and className.

  • The open image zooms out of its thumbnail and, on close, back into the thumbnail of whichever image is showing, even after swiping.
  • Thumbnails keep each image's own aspect ratio (a masonry column layout), so the zoom scales evenly instead of stretching. Pass width and height for every image.
  • Swipe or drag sideways to change images; a short drag springs back.

Accessibility

  • Thumbnails are buttons that open a real dialog: focus moves inside, Escape or the close button dismisses it, and focus returns to the thumbnail.
  • Left and right arrow keys move between images, and the position (3 / 6) is announced.
  • With reduced motion enabled, images appear and change with a fade instead of zooming and sliding.