## Lightbox — Gesture Zoom that returns where it started. Docs: https://www.interior.dev/docs/lightbox Reference: https://www.interior.dev/reference/lightbox License: https://github.com/ddoemonn/interior/blob/main/LICENSE (MIT) ### Install Requires a React project. Install `motion`; the styled example uses Tailwind CSS utilities. The source file is copied into your project. `bunx shadcn@latest add https://www.interior.dev/r/lightbox.json` Or run `bun add motion` and copy the source below. ### Usage ```tsx "use client"; import { useRef, useState } from "react"; import { Lightbox } from "@/components/interior/lightbox"; type Shot = { src: string; title: string }; export function ProjectGallery({ shots }: { shots: Shot[] }) { const [active, setActive] = useState(shots[0]); const [open, setOpen] = useState(false); const originRef = useRef(null); return (
{shots.map((shot) => ( ))} setOpen(false)} originRef={originRef} src={active.src} alt={active.title} caption={active.title} width={1200} height={800} maxScale={5} />
); } ``` ### Props - `open` (`boolean`) Whether the overlay is mounted. The caller owns it, so the trigger stays the caller's element. - `onClose` (`() => void`) Fired by Escape, the close button, and a click on the backdrop while unzoomed. The overlay stays mounted until the return flight finishes. - `src` (`string`) Full-size image source. Keep it set through the close animation; clearing it mid-exit leaves a broken image flying home. - `alt` (`string`) Names the image and, when no caption is given, labels the dialog. - `originRef` (`React.RefObject`) — default: `undefined` The element the image should return to. Point it at the clicked thumbnail in the click handler. Omitted, the overlay falls back to a 0.97 scale enter and exit. - `caption` (`string`) — default: `undefined` Label shown top-left and used as the dialog's accessible name. - `width` (`number`) — default: `undefined` Intrinsic pixel width. Reserves the image box before the file loads so the opening flight has something to measure. - `height` (`number`) — default: `undefined` Intrinsic pixel height, paired with width. - `maxScale` (`number`) — default: `4` Ceiling for wheel, double-click and keyboard zoom. Values at or below 1.1 are raised to 1.1. - `useLightbox.steps` (`number`) — default: `8` Hook option only. Quantises how often zoom progress reaches React; the component draws no meter. - `className` (`string`) — default: `""` Appended last to the fixed overlay shell. ### Behavior notes - The image returns to the rect its trigger occupies at the moment of closing, not the one cached when it opened, so a scroll, a resize, or a reflow behind the overlay cannot make it land beside the thumbnail. - The wheel zooms toward the cursor, so the detail under the pointer stays under the pointer; double-click toggles between fit and a close-up centred on the click, and a single drag pans while zoomed. - Pan is clamped against the image's own edges at every scale, so the picture cannot be flung past the frame and abandoned in empty space, and any release under 1.05x snaps back to exactly 1.0 rather than to 1.01. - Escape unwinds the zoom on the first press and closes on the second, so a close-up is never a one way trip out of the viewer; the trigger gets focus back when the overlay leaves, and only if it is still in the document. - Scale and offset are written to the node through motion values, so a drag does not re-render React; the zoom readout moves in whole cells and the screen reader is told the settled level once, 220ms after the wheel stops. - Reduced motion keeps the overlay, the zoom and the return, and drops only the travel between them; the body scroll lock adds the scrollbar's width to the padding the page already had, so nothing underneath shifts. ### Source (`components/interior/lightbox.tsx`) ```tsx "use client"; import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState, } from "react"; import { createPortal } from "react-dom"; import { AnimatePresence, animate, motion, useMotionValue, useReducedMotion, } from "motion/react"; const CELL = { type: "spring", stiffness: 520, damping: 34, mass: 0.45, } as const; const HOME = { type: "spring", stiffness: 150, damping: 27, mass: 1, } as const; const VEIL = { type: "spring", stiffness: 260, damping: 34, mass: 0.8, } as const; const EASE = [0.23, 1, 0.32, 1] as const; const EXIT = { duration: 0.2, ease: [0.4, 0, 1, 1] } as const; const GLYPH = { type: "spring", stiffness: 700, damping: 46, mass: 0.5, } as const; const TOGGLE = 2.5; const KEY_ZOOM = 1.6; const KEY_PAN = 56; const WHEEL_RATE = 140; const SLOP = 8; const NEAR_HOME = 1.02; const SNAP_HOME = 1.05; const CHROME_BUTTON = "grid size-8 place-items-center rounded-[9px] border border-stone-200 bg-white text-stone-500 outline-none transition-[border-color,color,box-shadow] duration-150 hover:border-stone-300 hover:text-stone-700 focus-visible:border-[#4568FF] focus-visible:shadow-[0_1px_2px_rgba(28,25,23,0.08),0_10px_20px_-14px_rgba(69,104,255,0.6)] dark:border-white/[0.16] dark:bg-[#1D1D1A] dark:text-stone-400 dark:hover:border-white/20 dark:hover:text-stone-200 dark:focus-visible:border-[#93B0FF] dark:focus-visible:shadow-[0_10px_20px_-14px_rgba(147,176,255,0.5)]"; type Spring = { type: "spring"; stiffness: number; damping: number; mass: number; }; const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v)); type Drag = { id: number; from: { x: number; y: number }; x: number; y: number; }; export type UseLightboxOptions = { maxScale?: number; steps?: number; disabled?: boolean; onDismiss?: () => void; }; export function useLightbox< Frame extends HTMLElement = HTMLDivElement, Content extends HTMLElement = HTMLImageElement, >({ maxScale = 4, steps = 8, disabled = false, onDismiss, }: UseLightboxOptions = {}) { const cells = Math.max(1, Math.round(steps)); const top = Math.max(1.1, maxScale); const frameRef = useRef(null); const contentRef = useRef(null); const scale = useMotionValue(1); const x = useMotionValue(0); const y = useMotionValue(0); const [step, setStep] = useState(0); const [settled, setSettled] = useState(0); const stepRef = useRef(0); const settledRef = useRef(0); const timer = useRef | null>(null); const drag = useRef(null); const onContent = useRef(false); const reduced = useReducedMotion(); const dismiss = useRef(onDismiss); dismiss.current = onDismiss; const toStep = useCallback( (s: number) => clamp(Math.round(((s - 1) / (top - 1)) * cells), 0, cells), [cells, top], ); const mark = useCallback( (s: number) => { const next = toStep(s); if (stepRef.current === next) return; stepRef.current = next; setStep(next); }, [toStep], ); const settle = useCallback( (s: number) => { if (timer.current) { clearTimeout(timer.current); timer.current = null; } const next = toStep(s); if (settledRef.current === next) return; settledRef.current = next; setSettled(next); }, [toStep], ); const settleSoon = useCallback( (s: number) => { if (timer.current) clearTimeout(timer.current); timer.current = setTimeout(() => { timer.current = null; settle(s); }, 220); }, [settle], ); const limit = useCallback((s: number) => { const frame = frameRef.current; const content = contentRef.current; if (!frame || !content) return { mx: 0, my: 0 }; return { mx: Math.max(0, (content.offsetWidth * s - frame.clientWidth) / 2), my: Math.max(0, (content.offsetHeight * s - frame.clientHeight) / 2), }; }, []); const place = useCallback( (s: number, nx: number, ny: number) => { const { mx, my } = limit(s); scale.set(s); x.set(clamp(nx, -mx, mx)); y.set(clamp(ny, -my, my)); mark(s); }, [limit, mark, scale, x, y], ); const glide = useCallback( (s: number, nx: number, ny: number, spring: Spring = CELL) => { const { mx, my } = limit(s); const tx = clamp(nx, -mx, mx); const ty = clamp(ny, -my, my); if (reduced) { scale.set(s); x.set(tx); y.set(ty); } else { animate(scale, s, spring); animate(x, tx, spring); animate(y, ty, spring); } mark(s); settle(s); }, [limit, mark, reduced, scale, settle, x, y], ); const reset = useCallback(() => { glide(1, 0, 0, HOME); }, [glide]); const zoomAt = useCallback( (next: number, cx: number, cy: number, animated: boolean) => { const frame = frameRef.current; if (!frame) return; const r = frame.getBoundingClientRect(); const px = cx - (r.left + r.width / 2); const py = cy - (r.top + r.height / 2); const s0 = scale.get(); const ax = (px - x.get()) / s0; const ay = (py - y.get()) / s0; const s = clamp(next, 1, top); const nx = px - ax * s; const ny = py - ay * s; if (animated) { glide(s, nx, ny, s <= 1 ? HOME : CELL); return; } place(s, nx, ny); settleSoon(s); }, [glide, place, scale, settleSoon, top, x, y], ); const finish = useCallback(() => { const s0 = scale.get(); if (s0 < SNAP_HOME) reset(); else settle(s0); }, [reset, scale, settle]); const release = (e: React.PointerEvent) => { const held = drag.current; if (!held || held.id !== e.pointerId) return; drag.current = null; const moved = Math.hypot(e.clientX - held.from.x, e.clientY - held.from.y); if (moved < SLOP && !onContent.current && scale.get() <= NEAR_HOME) { dismiss.current?.(); return; } finish(); }; const cancel = (e: React.PointerEvent) => { const held = drag.current; if (!held || held.id !== e.pointerId) return; drag.current = null; finish(); }; const onKeyDown = (e: React.KeyboardEvent) => { const frame = frameRef.current; if (!frame || disabled) return; const r = frame.getBoundingClientRect(); const cx = r.left + r.width / 2; const cy = r.top + r.height / 2; const s0 = scale.get(); if (e.key === "+" || e.key === "=") { e.preventDefault(); zoomAt(s0 * KEY_ZOOM, cx, cy, true); return; } if (e.key === "-" || e.key === "_") { e.preventDefault(); zoomAt(s0 / KEY_ZOOM, cx, cy, true); return; } if (e.key === "0") { e.preventDefault(); reset(); return; } if (e.key === "Escape" && s0 > NEAR_HOME) { e.preventDefault(); e.stopPropagation(); reset(); return; } if (s0 > NEAR_HOME && e.key.startsWith("Arrow")) { e.preventDefault(); const dx = e.key === "ArrowLeft" ? KEY_PAN : e.key === "ArrowRight" ? -KEY_PAN : 0; const dy = e.key === "ArrowUp" ? KEY_PAN : e.key === "ArrowDown" ? -KEY_PAN : 0; glide(s0, x.get() + dx, y.get() + dy); } }; const bind = { onPointerDown: (e: React.PointerEvent) => { if (disabled) return; if (e.pointerType === "mouse" && e.button !== 0) return; const content = contentRef.current; onContent.current = content ? content.contains(e.target as Node) : false; e.currentTarget.setPointerCapture?.(e.pointerId); drag.current = { id: e.pointerId, from: { x: e.clientX, y: e.clientY }, x: x.get(), y: y.get(), }; }, onPointerMove: (e: React.PointerEvent) => { const held = drag.current; if (!held || held.id !== e.pointerId) return; if (scale.get() <= 1) return; place( scale.get(), held.x + (e.clientX - held.from.x), held.y + (e.clientY - held.from.y), ); }, onPointerUp: release, onPointerCancel: cancel, onLostPointerCapture: cancel, onDoubleClick: (e: React.MouseEvent) => { if (disabled) return; zoomAt( scale.get() > SNAP_HOME ? 1 : Math.min(TOGGLE, top), e.clientX, e.clientY, true, ); }, onKeyDown, }; useEffect(() => { const frame = frameRef.current; if (!frame) return; const onWheel = (e: WheelEvent) => { if (disabled) return; e.preventDefault(); zoomAt( scale.get() * Math.exp(-e.deltaY / WHEEL_RATE), e.clientX, e.clientY, false, ); }; frame.addEventListener("wheel", onWheel, { passive: false }); return () => frame.removeEventListener("wheel", onWheel); }, [disabled, scale, zoomAt]); useEffect(() => { const bail = () => { drag.current = null; }; window.addEventListener("blur", bail); return () => { window.removeEventListener("blur", bail); if (timer.current) clearTimeout(timer.current); }; }, []); return { frameRef, contentRef, bind, scale, x, y, step, steps: cells, zoom: 1 + (step / cells) * (top - 1), settledZoom: 1 + (settled / cells) * (top - 1), zoomed: step > 0, reset, zoomAt, }; } export type LightboxProps = { open: boolean; onClose: () => void; src: string; alt: string; originRef?: React.RefObject; caption?: string; width?: number; height?: number; maxScale?: number; className?: string; }; type Landing = { dx: number; dy: number; s: number; o: number; r: number }; function Stage({ onClose, src, alt, originRef, caption, width, height, maxScale = 4, className = "", }: LightboxProps) { const reduced = useReducedMotion(); const titleId = useId(); const hintId = useId(); const fx = useMotionValue(0); const fy = useMotionValue(0); const fs = useMotionValue(1); const fo = useMotionValue(0); const fr = useMotionValue(14); const { frameRef, contentRef, bind, scale, x, y, zoomed, settledZoom, reset, zoomAt, } = useLightbox({ maxScale, onDismiss: onClose }); const shellRef = useRef(null); const toggleZoom = useCallback(() => { const frame = frameRef.current; if (!frame) return; if (zoomed) { reset(); return; } const r = frame.getBoundingClientRect(); zoomAt( Math.min(TOGGLE, Math.max(1.1, maxScale)), r.left + r.width / 2, r.top + r.height / 2, true, ); }, [frameRef, maxScale, reset, zoomAt, zoomed]); const landing = useCallback((): Landing => { const frame = frameRef.current; const content = contentRef.current; const origin = originRef?.current; if (frame && content && origin && content.offsetWidth > 0) { const r = frame.getBoundingClientRect(); const o = origin.getBoundingClientRect(); if (o.width > 0) { const s = o.width / content.offsetWidth; const rad = Number.parseFloat(getComputedStyle(origin).borderTopLeftRadius) || 9; return { dx: o.left + o.width / 2 - (r.left + r.width / 2), dy: o.top + o.height / 2 - (r.top + r.height / 2), s, o: 1, r: rad / s, }; } } return { dx: 0, dy: 10, s: 0.97, o: 0, r: 14 }; }, [contentRef, frameRef, originRef]); useLayoutEffect(() => { if (reduced) { fx.set(0); fy.set(0); fs.set(1); fo.set(1); fr.set(14); return; } const d = landing(); fx.set(d.dx); fy.set(d.dy); fs.set(d.s); fo.set(d.o); fr.set(d.r); const runs = [ animate(fx, 0, HOME), animate(fy, 0, HOME), animate(fs, 1, HOME), animate(fo, 1, HOME), animate(fr, 14, HOME), ]; return () => runs.forEach((r) => r.stop()); }, [fo, fr, fs, fx, fy, landing, reduced]); const away = useCallback(() => { if (reduced) return { opacity: 0, transition: { duration: 0.12 } }; const d = landing(); animate(fr, d.r, HOME); return { x: d.dx, y: d.dy, scale: d.s, opacity: d.o, filter: "blur(4px)", transition: HOME, }; }, [fr, landing, reduced]); const unwind = useCallback( () => ({ x: 0, y: 0, scale: 1, transition: reduced ? { duration: 0 } : HOME, }), [reduced], ); useEffect(() => { const frame = frameRef.current; const previous = document.activeElement instanceof HTMLElement ? document.activeElement : null; const body = document.body; const overflow = body.style.overflow; const padding = body.style.paddingRight; const gap = window.innerWidth - document.documentElement.clientWidth; const base = Number.parseFloat(getComputedStyle(body).paddingRight) || 0; body.style.overflow = "hidden"; if (gap > 0) body.style.paddingRight = `${base + gap}px`; frame?.focus({ preventScroll: true }); return () => { body.style.overflow = overflow; body.style.paddingRight = padding; if (previous?.isConnected) previous.focus({ preventScroll: true }); }; }, [frameRef]); useEffect(() => { const shell = shellRef.current; if (!shell) return; const onKeyDown = (e: KeyboardEvent) => { if (e.key === "Escape") { if (zoomed) return; e.preventDefault(); onClose(); return; } if (e.key !== "Tab") return; const nodes = Array.from( shell.querySelectorAll('[data-lightbox-focus="1"]'), ); if (nodes.length === 0) return; e.preventDefault(); const here = document.activeElement instanceof HTMLElement ? nodes.indexOf(document.activeElement) : -1; const next = e.shiftKey ? here <= 0 ? nodes.length - 1 : here - 1 : here === -1 || here === nodes.length - 1 ? 0 : here + 1; nodes[next]?.focus(); }; shell.addEventListener("keydown", onKeyDown); return () => shell.removeEventListener("keydown", onKeyDown); }, [onClose, zoomed]); return (

{caption ?? alt}

Scroll to zoom toward the pointer, or press plus and minus. Drag or use the arrow keys to pan, and double-click to switch between fit and close-up. Press zero to return to the starting frame; Escape returns home first, then closes.

Zoom {settledZoom.toFixed(1)} times

); } export function Lightbox(props: LightboxProps) { const [mounted, setMounted] = useState(false); useEffect(() => { setMounted(true); }, []); if (!mounted) return null; return createPortal( {props.open ? : null} , document.body, ); } ```