{
  "name": "lightbox-grid",
  "type": "registry:component",
  "dependencies": [
    "motion"
  ],
  "files": [
    {
      "type": "registry:component",
      "content": "\"use client\";\n\n/**\n * @author: @dorianbaffier\n * @description: Lightbox Grid\n * @version: 1.0.0\n * @date: 2026-10-15\n * @license: MIT\n * @website: https://kokonutui.com\n * @github: https://github.com/kokonut-labs/kokonutui\n */\n\nimport {\n  animate,\n  type MotionValue,\n  motion,\n  useMotionValue,\n  useReducedMotion,\n  useTransform,\n} from \"motion/react\";\nimport Image from \"next/image\";\nimport {\n  type KeyboardEvent,\n  type PointerEvent,\n  type ReactNode,\n  type Ref,\n  useCallback,\n  useEffect,\n  useRef,\n  useState,\n} from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface LightboxImage {\n  src: string;\n  alt: string;\n  /** Natural size, used to fit the open view without waiting for a load. */\n  width: number;\n  height: number;\n  caption?: string;\n}\n\nexport interface LightboxGridProps {\n  images?: LightboxImage[];\n  className?: string;\n}\n\nconst unsplash = (id: string, w: number, h: number) =>\n  `https://images.unsplash.com/photo-${id}?w=${w}&h=${h}&fit=crop&auto=format&q=80`;\n\nconst DEFAULT_IMAGES: LightboxImage[] = [\n  {\n    src: unsplash(\"1501785888041-af3ef285b470\", 1600, 1067),\n    alt: \"A lake below mountains\",\n    width: 1600,\n    height: 1067,\n    caption: \"Lake shore, early morning\",\n  },\n  {\n    src: unsplash(\"1506905925346-21bda4d32df4\", 1200, 1600),\n    alt: \"Peaks above the clouds\",\n    width: 1200,\n    height: 1600,\n    caption: \"Above the cloud line\",\n  },\n  {\n    src: unsplash(\"1470071459604-3b5ec3a7fe05\", 1600, 1067),\n    alt: \"Misty hills\",\n    width: 1600,\n    height: 1067,\n    caption: \"Fog over the hills\",\n  },\n  {\n    src: unsplash(\"1441974231531-c6227db76b6e\", 1200, 1600),\n    alt: \"Light through a forest\",\n    width: 1200,\n    height: 1600,\n    caption: \"Forest, late afternoon\",\n  },\n  {\n    src: unsplash(\"1469474968028-56623f02e42e\", 1600, 1067),\n    alt: \"Sun setting over a valley\",\n    width: 1600,\n    height: 1067,\n    caption: \"Valley at sunset\",\n  },\n  {\n    src: unsplash(\"1433086966358-54859d0ed716\", 1200, 1600),\n    alt: \"A waterfall\",\n    width: 1200,\n    height: 1600,\n    caption: \"Waterfall after rain\",\n  },\n];\n\nconst EASE: [number, number, number, number] = [0.23, 1, 0.32, 1];\nconst OPEN = { type: \"spring\", duration: 0.5, bounce: 0.12 } as const;\nconst CLOSE = { type: \"spring\", duration: 0.42, bounce: 0.08 } as const;\nconst SETTLE = { type: \"spring\", duration: 0.35, bounce: 0.2 } as const;\n// The open image keeps this much room around it, and below for the caption.\nconst MARGIN = 48;\nconst MARGIN_SMALL = 12;\nconst CAPTION_ROOM = 56;\nconst OPEN_RADIUS = 10;\n// Drag thresholds: distance in px, speed in px/ms.\nconst DISMISS_DISTANCE = 110;\nconst DISMISS_SPEED = 0.55;\nconst SWIPE_SPEED = 0.45;\nconst AXIS_LOCK = 6;\n\ninterface Rect {\n  left: number;\n  top: number;\n  width: number;\n  height: number;\n}\n\ninterface Frame {\n  x: number;\n  y: number;\n  scale: number;\n  clip: string;\n}\n\n/** Where an image of this size sits when open, fitted to the window. */\nfunction fitRect(image: LightboxImage): Rect {\n  const vw = window.innerWidth;\n  const vh = window.innerHeight;\n  const margin = vw < 640 ? MARGIN_SMALL : MARGIN;\n  const maxW = vw - margin * 2;\n  const maxH = vh - margin * 2 - CAPTION_ROOM;\n  const scale = Math.min(maxW / image.width, maxH / image.height);\n  const width = image.width * scale;\n  const height = image.height * scale;\n  return {\n    left: (vw - width) / 2,\n    top: (vh - CAPTION_ROOM - height) / 2,\n    width,\n    height,\n  };\n}\n\nconst openClip = () => `inset(0px 0px 0px 0px round ${OPEN_RADIUS}px)`;\n\n/**\n * The transform and crop that make the open image look exactly like its\n * thumbnail: scaled down to the thumbnail's cover size, moved onto it, and\n * cropped to its box. Animating from here to identity is the whole trick.\n */\nfunction thumbFrame(\n  open: Rect,\n  thumb: DOMRect,\n  image: LightboxImage,\n  radius: number\n): Frame {\n  const imageRatio = image.width / image.height;\n  const thumbRatio = thumb.width / thumb.height;\n  const coverWidth =\n    imageRatio > thumbRatio ? thumb.height * imageRatio : thumb.width;\n  const scale = coverWidth / open.width;\n  const insetX = (open.width - thumb.width / scale) / 2;\n  const insetY = (open.height - thumb.height / scale) / 2;\n  return {\n    x: thumb.left + thumb.width / 2 - (open.left + open.width / 2),\n    y: thumb.top + thumb.height / 2 - (open.top + open.height / 2),\n    scale,\n    clip: `inset(${insetY}px ${insetX}px ${insetY}px ${insetX}px round ${radius / scale}px)`,\n  };\n}\n\nfunction isVisible(rect: DOMRect) {\n  return (\n    rect.bottom > 0 &&\n    rect.top < window.innerHeight &&\n    rect.right > 0 &&\n    rect.left < window.innerWidth\n  );\n}\n\nfunction IconButton({\n  label,\n  onClick,\n  className,\n  children,\n  buttonRef,\n}: {\n  label: string;\n  onClick: () => void;\n  className?: string;\n  children: ReactNode;\n  buttonRef?: Ref<HTMLButtonElement>;\n}) {\n  return (\n    <button\n      aria-label={label}\n      className={cn(\n        \"flex size-10 items-center justify-center rounded-full bg-white/10 text-white transition-colors duration-150 hover:bg-white/20 focus-visible:outline-2 focus-visible:outline-white/70 disabled:pointer-events-none disabled:opacity-30\",\n        className\n      )}\n      onClick={onClick}\n      ref={buttonRef}\n      type=\"button\"\n    >\n      {children}\n    </button>\n  );\n}\n\nfunction Chevron({ flip }: { flip?: boolean }) {\n  return (\n    <svg\n      aria-hidden=\"true\"\n      className={cn(\"size-4\", flip && \"rotate-180\")}\n      fill=\"none\"\n      viewBox=\"0 0 16 16\"\n    >\n      <path\n        d=\"m6 3.5 4.5 4.5L6 12.5\"\n        stroke=\"currentColor\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n        strokeWidth=\"1.6\"\n      />\n    </svg>\n  );\n}\n\ninterface ViewerProps {\n  images: LightboxImage[];\n  index: number;\n  rect: Rect;\n  x: MotionValue<number>;\n  y: MotionValue<number>;\n  scale: MotionValue<number>;\n  clip: MotionValue<string>;\n  alpha: MotionValue<number>;\n  fade: MotionValue<number>;\n  settled: boolean;\n  onClose: (velocity?: { x: number; y: number }) => void;\n  onStep: (direction: 1 | -1) => void;\n}\n\nfunction Viewer({\n  images,\n  index,\n  rect,\n  x,\n  y,\n  scale,\n  clip,\n  alpha,\n  fade,\n  settled,\n  onClose,\n  onStep,\n}: ViewerProps) {\n  const closeRef = useRef<HTMLButtonElement>(null);\n  const dialogRef = useRef<HTMLDivElement>(null);\n  const drag = useRef<{\n    id: number;\n    startX: number;\n    startY: number;\n    axis: \"x\" | \"y\" | null;\n    samples: { x: number; y: number; t: number }[];\n  } | null>(null);\n  const backdrop = useTransform(fade, (v) => v * 0.92);\n  const image = images[index];\n  const hasPrev = index > 0;\n  const hasNext = index < images.length - 1;\n\n  useEffect(() => {\n    closeRef.current?.focus({ preventScroll: true });\n  }, []);\n\n  const handlePointerDown = (event: PointerEvent<HTMLDivElement>) => {\n    if (!settled || event.button !== 0) {\n      return;\n    }\n    event.currentTarget.setPointerCapture(event.pointerId);\n    drag.current = {\n      id: event.pointerId,\n      startX: event.clientX,\n      startY: event.clientY,\n      axis: null,\n      samples: [{ x: event.clientX, y: event.clientY, t: event.timeStamp }],\n    };\n  };\n\n  // Pulling away shrinks the photo and lets the page show through.\n  const followVertical = (dx: number, dy: number) => {\n    const pull = Math.min(Math.abs(dy) / window.innerHeight, 1);\n    x.set(dx);\n    y.set(dy);\n    scale.set(1 - pull * 0.45);\n    fade.set(1 - Math.min(Math.abs(dy) / 360, 1) * 0.85);\n  };\n\n  // Past the first or last photo the drag only gives a little.\n  const followHorizontal = (dx: number) => {\n    const blocked = (dx > 0 && !hasPrev) || (dx < 0 && !hasNext);\n    x.set(blocked ? dx * 0.25 : dx);\n  };\n\n  const handlePointerMove = (event: PointerEvent<HTMLDivElement>) => {\n    const d = drag.current;\n    if (!d || d.id !== event.pointerId) {\n      return;\n    }\n    const dx = event.clientX - d.startX;\n    const dy = event.clientY - d.startY;\n    if (!d.axis) {\n      if (Math.hypot(dx, dy) < AXIS_LOCK) {\n        return;\n      }\n      d.axis = Math.abs(dx) > Math.abs(dy) ? \"x\" : \"y\";\n    }\n    d.samples.push({ x: event.clientX, y: event.clientY, t: event.timeStamp });\n    if (d.samples.length > 5) {\n      d.samples.shift();\n    }\n    if (d.axis === \"y\") {\n      followVertical(dx, dy);\n    } else {\n      followHorizontal(dx);\n    }\n  };\n\n  const releaseVertical = (dy: number, velocity: { x: number; y: number }) => {\n    if (\n      Math.abs(dy) > DISMISS_DISTANCE ||\n      Math.abs(velocity.y) > DISMISS_SPEED\n    ) {\n      onClose(velocity);\n      return;\n    }\n    animate(x, 0, SETTLE);\n    animate(y, 0, SETTLE);\n    animate(scale, 1, SETTLE);\n    animate(fade, 1, { duration: 0.2, ease: EASE });\n  };\n\n  const releaseHorizontal = (dx: number, vx: number) => {\n    const far = Math.abs(dx) > rect.width * 0.22 || Math.abs(vx) > SWIPE_SPEED;\n    if (far && dx < 0 && hasNext) {\n      onStep(1);\n    } else if (far && dx > 0 && hasPrev) {\n      onStep(-1);\n    } else {\n      animate(x, 0, SETTLE);\n    }\n  };\n\n  const handlePointerUp = (event: PointerEvent<HTMLDivElement>) => {\n    const d = drag.current;\n    if (!d || d.id !== event.pointerId) {\n      return;\n    }\n    drag.current = null;\n    const first = d.samples[0];\n    const last = d.samples.at(-1) ?? first;\n    const dt = Math.max(1, last.t - first.t);\n    const velocity = { x: (last.x - first.x) / dt, y: (last.y - first.y) / dt };\n    if (d.axis === \"y\") {\n      releaseVertical(event.clientY - d.startY, velocity);\n    } else if (d.axis === \"x\") {\n      releaseHorizontal(event.clientX - d.startX, velocity.x);\n    }\n  };\n\n  const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {\n    if (event.key === \"Escape\") {\n      event.preventDefault();\n      onClose();\n    } else if (event.key === \"ArrowRight\" && hasNext) {\n      onStep(1);\n    } else if (event.key === \"ArrowLeft\" && hasPrev) {\n      onStep(-1);\n    } else if (event.key === \"Tab\") {\n      // Keep focus inside the viewer.\n      const focusable = Array.from(\n        dialogRef.current?.querySelectorAll<HTMLButtonElement>(\n          \"button:not(:disabled)\"\n        ) ?? []\n      );\n      const first = focusable[0];\n      const last = focusable.at(-1);\n      if (event.shiftKey && document.activeElement === first) {\n        event.preventDefault();\n        last?.focus();\n      } else if (!event.shiftKey && document.activeElement === last) {\n        event.preventDefault();\n        first?.focus();\n      }\n    }\n  };\n\n  return (\n    // biome-ignore lint/a11y/noNoninteractiveElementInteractions: keys for the whole viewer: Escape, arrows and the focus trap\n    <div\n      aria-label={image.caption ?? image.alt}\n      aria-modal=\"true\"\n      className=\"fixed inset-0 z-[100]\"\n      onKeyDown={handleKeyDown}\n      ref={dialogRef}\n      role=\"dialog\"\n    >\n      <motion.div\n        aria-hidden=\"true\"\n        className=\"absolute inset-0 bg-zinc-950\"\n        onClick={() => onClose()}\n        style={{ opacity: backdrop }}\n      />\n\n      <motion.div\n        className=\"absolute cursor-grab touch-none select-none overflow-hidden active:cursor-grabbing\"\n        onLostPointerCapture={handlePointerUp}\n        onPointerCancel={handlePointerUp}\n        onPointerDown={handlePointerDown}\n        onPointerMove={handlePointerMove}\n        onPointerUp={handlePointerUp}\n        style={{\n          left: rect.left,\n          top: rect.top,\n          width: rect.width,\n          height: rect.height,\n          x,\n          y,\n          scale,\n          clipPath: clip,\n          opacity: alpha,\n          willChange: \"transform, clip-path\",\n        }}\n      >\n        <Image\n          alt={image.alt}\n          className=\"pointer-events-none h-full w-full object-cover\"\n          draggable={false}\n          height={image.height}\n          src={image.src}\n          unoptimized\n          width={image.width}\n        />\n      </motion.div>\n\n      <motion.div\n        className=\"pointer-events-none absolute inset-0\"\n        style={{ opacity: fade }}\n      >\n        <div className=\"pointer-events-auto absolute top-3 right-3 flex items-center gap-3 sm:top-4 sm:right-4\">\n          <span className=\"text-sm text-white/60 tabular-nums\">\n            {index + 1} / {images.length}\n          </span>\n          <IconButton\n            buttonRef={closeRef}\n            label=\"Close\"\n            onClick={() => onClose()}\n          >\n            <svg\n              aria-hidden=\"true\"\n              className=\"size-4\"\n              fill=\"none\"\n              viewBox=\"0 0 16 16\"\n            >\n              <path\n                d=\"m4 4 8 8M12 4l-8 8\"\n                stroke=\"currentColor\"\n                strokeLinecap=\"round\"\n                strokeWidth=\"1.6\"\n              />\n            </svg>\n          </IconButton>\n        </div>\n        <IconButton\n          className=\"pointer-events-auto absolute top-1/2 left-4 hidden -translate-y-1/2 sm:flex\"\n          label=\"Previous photo\"\n          onClick={() => hasPrev && onStep(-1)}\n        >\n          <Chevron flip />\n        </IconButton>\n        <IconButton\n          className=\"pointer-events-auto absolute top-1/2 right-4 hidden -translate-y-1/2 sm:flex\"\n          label=\"Next photo\"\n          onClick={() => hasNext && onStep(1)}\n        >\n          <Chevron />\n        </IconButton>\n        {image.caption ? (\n          <p\n            className=\"absolute inset-x-4 text-center text-sm text-white/80\"\n            style={{ top: rect.top + rect.height + 18 }}\n          >\n            {image.caption}\n          </p>\n        ) : null}\n      </motion.div>\n    </div>\n  );\n}\n\n/**\n * A photo grid whose thumbnails grow into a full view. The open photo is the\n * same element at every moment: a transform and a crop take it from its\n * slot to the middle of the screen and back, wherever it was dragged to.\n */\nexport default function LightboxGrid({\n  images = DEFAULT_IMAGES,\n  className,\n}: LightboxGridProps) {\n  const [active, setActive] = useState<number | null>(null);\n  const [rect, setRect] = useState<Rect | null>(null);\n  const [settled, setSettled] = useState(false);\n  const [closing, setClosing] = useState(false);\n  const thumbs = useRef<(HTMLButtonElement | null)[]>([]);\n  const reduceMotion = useReducedMotion();\n  const x = useMotionValue(0);\n  const y = useMotionValue(0);\n  const scale = useMotionValue(1);\n  const clip = useMotionValue(openClip());\n  const alpha = useMotionValue(1);\n  const fade = useMotionValue(0);\n\n  const frameFor = useCallback(\n    (index: number, open: Rect): Frame | null => {\n      const thumb = thumbs.current[index];\n      if (!thumb) {\n        return null;\n      }\n      const box = thumb.getBoundingClientRect();\n      if (!isVisible(box)) {\n        return null;\n      }\n      const radius =\n        Number.parseFloat(getComputedStyle(thumb).borderTopLeftRadius) || 0;\n      return thumbFrame(open, box, images[index], radius);\n    },\n    [images]\n  );\n\n  const open = useCallback(\n    (index: number) => {\n      const target = fitRect(images[index]);\n      const from = frameFor(index, target);\n      setActive(index);\n      setRect(target);\n      setClosing(false);\n      setSettled(false);\n      alpha.set(1);\n      if (!from || reduceMotion) {\n        x.set(0);\n        y.set(0);\n        scale.set(1);\n        clip.set(openClip());\n        fade.set(0);\n        alpha.set(0);\n        animate(alpha, 1, { duration: 0.18 });\n        animate(fade, 1, { duration: 0.18 }).then(() => setSettled(true));\n        return;\n      }\n      x.set(from.x);\n      y.set(from.y);\n      scale.set(from.scale);\n      clip.set(from.clip);\n      fade.set(0);\n      animate(x, 0, OPEN);\n      animate(y, 0, OPEN);\n      animate(scale, 1, OPEN);\n      animate(clip, openClip(), OPEN);\n      animate(fade, 1, { duration: 0.3, ease: EASE }).then(() =>\n        setSettled(true)\n      );\n    },\n    [alpha, clip, fade, frameFor, images, reduceMotion, scale, x, y]\n  );\n\n  const close = useCallback(\n    (velocity?: { x: number; y: number }) => {\n      if (active === null || !rect || closing) {\n        return;\n      }\n      setClosing(true);\n      const index = active;\n      // Instant, so the slot is measured where it will be (pages often scroll smoothly).\n      thumbs.current[index]?.scrollIntoView({\n        behavior: \"instant\",\n        block: \"nearest\",\n        inline: \"nearest\",\n      });\n      const to = frameFor(index, rect);\n      const finish = () => {\n        setActive(null);\n        setRect(null);\n        setClosing(false);\n        thumbs.current[index]?.focus({ preventScroll: true });\n      };\n      if (!to || reduceMotion) {\n        animate(alpha, 0, { duration: 0.16 });\n        animate(fade, 0, { duration: 0.16 }).then(finish);\n        return;\n      }\n      // A flick carries its speed into the flight home.\n      const vx = (velocity?.x ?? 0) * 1000;\n      const vy = (velocity?.y ?? 0) * 1000;\n      animate(x, to.x, { ...CLOSE, velocity: vx });\n      animate(y, to.y, { ...CLOSE, velocity: vy });\n      animate(scale, to.scale, CLOSE);\n      animate(clip, to.clip, CLOSE);\n      animate(fade, 0, { duration: 0.28, ease: EASE });\n      // The clip spring settles last; reveal the thumbnail when it lands.\n      window.setTimeout(finish, 420);\n    },\n    [\n      active,\n      alpha,\n      clip,\n      closing,\n      fade,\n      frameFor,\n      rect,\n      reduceMotion,\n      scale,\n      x,\n      y,\n    ]\n  );\n\n  const step = useCallback(\n    (direction: 1 | -1) => {\n      if (active === null) {\n        return;\n      }\n      const next = active + direction;\n      if (next < 0 || next >= images.length) {\n        return;\n      }\n      setActive(next);\n      setRect(fitRect(images[next]));\n      y.set(0);\n      scale.set(1);\n      clip.set(openClip());\n      fade.set(1);\n      if (reduceMotion) {\n        x.set(0);\n        alpha.set(1);\n        return;\n      }\n      // The next photo slides in from the side it was swiped from.\n      x.set(direction * 72);\n      alpha.set(0);\n      animate(x, 0, { duration: 0.32, ease: EASE });\n      animate(alpha, 1, { duration: 0.22, ease: EASE });\n    },\n    [active, alpha, clip, fade, images, reduceMotion, scale, x, y]\n  );\n\n  // Lock the page behind the viewer and keep the open photo fitted.\n  useEffect(() => {\n    if (active === null) {\n      return;\n    }\n    const root = document.documentElement;\n    const previous = root.style.overflow;\n    root.style.overflow = \"hidden\";\n    const onResize = () => setRect(fitRect(images[active]));\n    window.addEventListener(\"resize\", onResize);\n    return () => {\n      root.style.overflow = previous;\n      window.removeEventListener(\"resize\", onResize);\n    };\n  }, [active, images]);\n\n  return (\n    <div className={cn(\"w-full max-w-xl\", className)}>\n      <ul className=\"grid grid-cols-3 gap-2\">\n        {images.map((image, index) => (\n          <li\n            className={cn(index === 0 && \"col-span-2 row-span-2\")}\n            key={image.src}\n          >\n            <button\n              aria-label={`Open ${image.alt}`}\n              className=\"relative block aspect-square w-full overflow-hidden rounded-xl bg-zinc-100 outline-offset-2 focus-visible:outline-2 focus-visible:outline-zinc-900 dark:bg-zinc-800 dark:focus-visible:outline-white\"\n              onClick={() => open(index)}\n              ref={(node) => {\n                thumbs.current[index] = node;\n              }}\n              // The open photo has left its slot; the slot stays empty until it returns.\n              style={{ opacity: active === index ? 0 : 1 }}\n              type=\"button\"\n            >\n              <Image\n                alt=\"\"\n                className=\"h-full w-full object-cover transition-[filter] duration-200 hover:brightness-95\"\n                draggable={false}\n                fill\n                sizes=\"(max-width: 640px) 66vw, 384px\"\n                src={image.src}\n                unoptimized\n              />\n            </button>\n          </li>\n        ))}\n      </ul>\n      {active !== null && rect\n        ? createPortal(\n            <Viewer\n              alpha={alpha}\n              clip={clip}\n              fade={fade}\n              images={images}\n              index={active}\n              onClose={close}\n              onStep={step}\n              rect={rect}\n              scale={scale}\n              settled={settled && !closing}\n              x={x}\n              y={y}\n            />,\n            document.body\n          )\n        : null}\n    </div>\n  );\n}\n",
      "path": "/components/kokonutui/lightbox-grid.tsx",
      "target": "components/kokonutui/lightbox-grid.tsx"
    }
  ]
}