"use client"; import { ChevronLeft, ChevronRight, Minus, Plus, X } from "lucide-react"; import { type HTMLMotionProps, type Variants, AnimatePresence, LayoutGroup, motion, useMotionValue, useIsPresent, useReducedMotion, } from "motion/react"; import { type ComponentPropsWithRef, type ReactNode, createContext, useContext, useEffect, useId, useLayoutEffect, useRef, useState, } from "react"; import { createPortal } from "react-dom"; import { EASE_DRAWER, EASE_OUT, SPRING_LAYOUT, SPRING_PRESS, } from "@/lib/ease"; import { useModalScope } from "@/lib/hooks/use-modal-scope"; import { PresenceGate } from "@/lib/presence-gate"; import { cn } from "@/lib/utils"; export interface LightboxImage { id: string; src: string; alt: string; /** Intrinsic dimensions preserve aspect ratio through the thumbnail morph. */ width: number; height: number; caption?: string; } export interface MorphingLightboxProps { images: LightboxImage[]; value?: string | null; defaultValue?: string | null; onValueChange?: (id: string | null) => void; label?: string; className?: string; thumbnailClassName?: string; renderCaption?: (image: LightboxImage) => ReactNode; /** Compose gallery and viewer parts instead of the default layout. */ children?: ReactNode; } type ImageSwap = { direction: -1 | 1; reduce: boolean }; // Paging needs a definite arrival; an overdamped panel spring creeps through // the last few pixels before snapping to its rest threshold. const IMAGE_SLIDE_TRANSITION = { type: "tween", duration: 0.28, ease: EASE_DRAWER, } as const; // Use Motion's x channel so the final slide position is painted in the same // frame as its animation. A native transform animation can briefly expose its // initial inline transform when it releases ownership at completion. const imageSwapVariants: Variants = { enter: ({ direction, reduce }: ImageSwap) => ({ opacity: reduce ? 0 : 1, x: `${reduce ? 0 : direction * 100}%`, }), visible: ({ reduce }: ImageSwap) => ({ opacity: 1, x: "0%", transition: reduce ? { duration: 0.1, ease: EASE_OUT } : IMAGE_SLIDE_TRANSITION, }), exit: ({ direction, reduce }: ImageSwap) => ({ opacity: reduce ? 0 : 1, x: `${reduce ? 0 : direction * -100}%`, transition: reduce ? { duration: 0.1, ease: EASE_OUT } : IMAGE_SLIDE_TRANSITION, }), }; const controlClass = "inline-flex size-10 shrink-0 items-center justify-center rounded-full bg-white/10 text-white transition-colors hover:bg-white/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white disabled:opacity-30"; export type ImageViewerImage = LightboxImage; export type ImageViewerProps = MorphingLightboxProps; type ViewerContextValue = { images: LightboxImage[]; value: string | null; image: LightboxImage | undefined; index: number; label: string; groupId: string; reduce: boolean; renderCaption?: (image: LightboxImage) => ReactNode; select: (id: string | null) => void; move: (direction: -1 | 1) => void; }; const ViewerContext = createContext(null); function useViewerContext(part: string) { const context = useContext(ViewerContext); if (!context) throw new Error(`${part} must be used within ImageViewer.`); return context; } /** Shared selection and navigation for custom gallery and viewer controls. */ export function useImageViewer() { const { images, value, image, index, select, move } = useViewerContext("useImageViewer"); return { images, value, image, index, hasPrevious: index > 0, hasNext: index >= 0 && index < images.length - 1, select, close: () => select(null), previous: () => move(-1), next: () => move(1), }; } /** A composable image gallery with a focus-managed, thumbnail-connected viewer. */ export function ImageViewer({ images, value: controlledValue, defaultValue = null, onValueChange, label = "Image gallery", className, thumbnailClassName, renderCaption, children, }: ImageViewerProps) { const [internalValue, setInternalValue] = useState(defaultValue); const value = controlledValue === undefined ? internalValue : controlledValue; const index = images.findIndex((image) => image.id === value); const image = images[index]; const groupId = useId(); const reduce = useReducedMotion() ?? false; if (new Set(images.map((item) => item.id)).size !== images.length) throw new Error("ImageViewer requires unique image ids."); if ( images.some( (item) => !Number.isFinite(item.width) || !Number.isFinite(item.height) || item.width <= 0 || item.height <= 0, ) ) throw new Error("ImageViewer requires positive image dimensions."); const select = (id: string | null) => { if (controlledValue === undefined) setInternalValue(id); onValueChange?.(id); }; // Drop removed identities instead of letting a re-added image reopen a session. if (controlledValue === undefined && internalValue !== null && index < 0) setInternalValue(null); const move = (direction: -1 | 1) => { const next = images[index + direction]; if (image && next) select(next.id); }; return ( {children === undefined ? ( <> {images.map((item) => ( ))} ) : ( children )} ); } /** Generic name; the original export remains compatible with existing installs. */ export { ImageViewer as MorphingLightbox }; export function ImageViewerGallery({ className, ...props }: ComponentPropsWithRef<"section">) { const { label } = useViewerContext("ImageViewerGallery"); return (
); } export interface ImageViewerThumbnailProps extends Omit, "children"> { imageId: string; imageClassName?: string; children?: ReactNode; } /** The image keeps its shared-layout identity when the trigger is customised. */ export function ImageViewerThumbnail({ imageId, className, imageClassName, children, onClick, disabled, ...props }: ImageViewerThumbnailProps) { const { images, value, groupId, reduce, select } = useViewerContext( "ImageViewerThumbnail", ); const image = images.find((item) => item.id === imageId); if (!image) throw new Error(`ImageViewerThumbnail: unknown image id "${imageId}".`); return ( { onClick?.(event); if (!event.defaultPrevented && !disabled) select(image.id); }} className={cn( "relative overflow-hidden rounded-2xl bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2", className, )} > {/* biome-ignore lint/performance/noImgElement: Copy-paste registry images must work outside Next.js. */} {children} ); } export function ImageViewerCounter({ className, ...props }: ComponentPropsWithRef<"span">) { const { index, images } = useViewerContext("ImageViewerCounter"); return ( {index + 1} / {images.length} ); } export function ImageViewerCaption({ className, children, ...props }: ComponentPropsWithRef<"div">) { const { image, renderCaption } = useViewerContext("ImageViewerCaption"); return (
{children ?? (image && (renderCaption ? renderCaption(image) : (image.caption ?? image.alt)))}
); } type ViewerButtonProps = ComponentPropsWithRef<"button">; function ViewerButton({ action, className, children, disabled, onClick, ...props }: ViewerButtonProps & { action: "close" | "previous" | "next" }) { const { index, images, select, move } = useViewerContext( "ImageViewer controls", ); const unavailable = action === "previous" ? index <= 0 : action === "next" ? index < 0 || index >= images.length - 1 : false; const Icon = action === "close" ? X : action === "previous" ? ChevronLeft : ChevronRight; return ( ); } export function ImageViewerClose(props: ViewerButtonProps) { return ; } export function ImageViewerPrevious(props: ViewerButtonProps) { return ; } export function ImageViewerNext(props: ViewerButtonProps) { return ; } export interface ImageViewerContentProps { className?: string; /** Custom header; defaults to the counter and close control. */ header?: ReactNode; /** Custom footer; defaults to navigation and the image caption. */ children?: ReactNode; } /** Owns the portal, focus scope, swipe/zoom frame and exit interaction gate. */ export function ImageViewerContent({ className, header, children, }: ImageViewerContentProps) { const context = useViewerContext("ImageViewerContent"); const { image, index, label, groupId, reduce, select, move } = context; const [mounted, setMounted] = useState(false); const portal = useRef(null); const panel = useRef(null); useEffect(() => { setMounted(true); }, []); useModalScope(mounted && Boolean(image), portal, panel, () => select(null)); const selectedId = image?.id; const previousId = useRef(undefined); useLayoutEffect(() => { const previous = previousId.current; previousId.current = mounted ? selectedId : undefined; if (!mounted || !selectedId || !previous || !panel.current) return; const focused = document.activeElement; if ( !panel.current.contains(focused) || (focused instanceof HTMLButtonElement && focused.disabled) ) { ( panel.current.querySelector( "button:not(:disabled)", ) ?? panel.current ).focus({ preventScroll: true }); } }, [mounted, selectedId]); if (!mounted) return null; return createPortal(
{image && ( {({ isPresent, gate }) => ( )} )}
, document.body, ); } function LightboxFrame({ image, index, layoutId, reduce, onSwipe, }: { image: LightboxImage; index: number; layoutId?: string; reduce: boolean; onSwipe: (direction: -1 | 1) => void; }) { const [zoom, setZoom] = useState(false); const [failed, setFailed] = useState(false); const session = `${image.id}-${image.src}`; const [selection, setSelection] = useState<{ session: string; index: number; direction: -1 | 1; }>({ session, index, direction: 1 }); const changed = selection.session !== session; const direction = changed ? index < selection.index ? -1 : 1 : selection.direction; // Keep the shared-layout frame mounted; only the image layer changes identity. if (changed) { setSelection({ session, index, direction }); setZoom(false); setFailed(false); } const swipe = useRef<{ x: number; y: number } | null>(null); const present = useIsPresent(); const bounds = useRef(null); const imageBox = useRef(null); const x = useMotionValue(0); const y = useMotionValue(0); const [pan, setPan] = useState({ x: 0, y: 0 }); const panHelp = useId(); const positionSession = useRef(session); useLayoutEffect(() => { if (positionSession.current === session) return; positionSession.current = session; x.set(0); y.set(0); swipe.current = null; }, [session, x, y]); useLayoutEffect(() => { const measure = () => { if (!bounds.current || !imageBox.current) return; const next = { x: Math.max( 0, (imageBox.current.clientWidth * 2 - bounds.current.clientWidth) / 2, ), y: Math.max( 0, (imageBox.current.clientHeight * 2 - bounds.current.clientHeight) / 2, ), }; setPan(next); x.set(Math.max(-next.x, Math.min(next.x, x.get()))); y.set(Math.max(-next.y, Math.min(next.y, y.get()))); }; measure(); const observer = new ResizeObserver(measure); if (bounds.current) observer.observe(bounds.current); if (imageBox.current) observer.observe(imageBox.current); return () => observer.disconnect(); }, [x, y]); return (
{({ isPresent: slidePresent, gate }) => ( {/* biome-ignore lint/performance/noImgElement: Copy-paste registry images must work outside Next.js. */} { if (slidePresent && present) setFailed(true); }} // Keep Motion's drag/layout registration mounted across zoom changes. drag dragListener={zoom} dragConstraints={{ left: -pan.x, right: pan.x, top: -pan.y, bottom: pan.y, }} dragElastic={0.08} dragMomentum={false} animate={{ scale: zoom && present ? 2 : 1, ...(!present ? { x: 0, y: 0 } : {}), }} transition={reduce ? { duration: 0 } : SPRING_LAYOUT} onPointerDown={(event) => { // Swiping the image keeps keyboard focus on the controls. event.preventDefault(); if (!zoom && event.isPrimary) swipe.current = { x: event.clientX, y: event.clientY, }; }} onPointerUp={(event) => { const start = swipe.current; swipe.current = null; if (!start || zoom) return; const dx = event.clientX - start.x; const dy = event.clientY - start.y; if ( Math.abs(dx) > 50 && Math.abs(dx) > Math.abs(dy) * 1.5 ) onSwipe(dx < 0 ? 1 : -1); }} onPointerCancel={() => { swipe.current = null; }} className="block h-full w-full select-none object-contain" style={{ x, y, touchAction: zoom ? "none" : "pan-y", cursor: zoom ? "grab" : undefined, }} /> )}
{failed && (

Unable to load this image.

)}
When zoomed in, drag the image to pan, or keep this control focused and use the arrow keys.
); }