{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "photo-stamp",
  "type": "registry:ui",
  "title": "Photo Stamp",
  "description": "A photo that lifts off the page at full size over a blurred backdrop, the same element the whole way.",
  "registryDependencies": [
    "utils",
    "use-spring"
  ],
  "files": [
    {
      "path": "registry/loomui/photo-stamp.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { createPortal } from \"react-dom\"\n\nimport { cn } from \"@/lib/utils\"\nimport { useSpring, type SpringOptions } from \"@/registry/lib/use-spring\"\n\nexport interface PhotoStampProps extends Omit<\n  React.ComponentProps<\"figure\">,\n  \"children\"\n> {\n  /** The photo. Ship the full-size file: the open state is its real size. */\n  src: string\n  /** Describes the photo. It is also the trigger's accessible name. */\n  alt: string\n  /** Sits under the photo, in both states. */\n  caption?: React.ReactNode\n  /** Corner radius. Held steady in screen pixels through the lift. */\n  radius?: number\n  /** How much of the viewport the open photo fills, `0` to `1`. */\n  fill?: number\n  /** How the photo travels. */\n  spring?: SpringOptions\n  /** Sizes the photo at rest. The open size is worked out from it. */\n  imageClassName?: string\n}\n\ninterface Box {\n  x: number\n  y: number\n  width: number\n  height: number\n}\n\ninterface Geometry {\n  /** Where the photo sits on the page. */\n  from: Box\n  /** Where it is going. */\n  to: Box\n  /** `from` over `to`. One number, because the two boxes share an aspect. */\n  scale: number\n}\n\n/** Room kept under the open photo for the caption. */\nconst CAPTION_ROOM = 44\n/** An exit wants to be quicker than the entrance it undoes. */\nconst EXIT_RATE = 0.8\n/** Keys that scroll the page out from under an open photo. */\nconst SCROLL_KEYS = new Set([\n  \" \",\n  \"PageUp\",\n  \"PageDown\",\n  \"Home\",\n  \"End\",\n  \"ArrowUp\",\n  \"ArrowDown\",\n  \"ArrowLeft\",\n  \"ArrowRight\",\n])\n\n/**\n * The open box, fitted to the viewport at the aspect the photo already has on\n * the page. Matching the rendered aspect rather than the source file's means\n * the crop never changes on the way up, so one scale covers both axes and\n * nothing has to be un-distorted afterwards.\n */\nfunction fit(from: Box, fill: number, room: number): Box {\n  const maxWidth = window.innerWidth * fill\n  const maxHeight = window.innerHeight * fill - room\n  const aspect = from.width / from.height\n\n  let width = maxWidth\n  let height = width / aspect\n\n  if (height > maxHeight) {\n    height = maxHeight\n    width = height * aspect\n  }\n\n  return {\n    x: (window.innerWidth - width) / 2,\n    y: (window.innerHeight - room - height) / 2,\n    width,\n    height,\n  }\n}\n\nfunction measure(node: HTMLElement): Box {\n  const rect = node.getBoundingClientRect()\n  return { x: rect.x, y: rect.y, width: rect.width, height: rect.height }\n}\n\nfunction usePrefersReducedMotion() {\n  const [reduced, setReduced] = React.useState(false)\n\n  React.useEffect(() => {\n    const query = window.matchMedia(\"(prefers-reduced-motion: reduce)\")\n    const read = () => setReduced(query.matches)\n    read()\n    query.addEventListener(\"change\", read)\n    return () => query.removeEventListener(\"change\", read)\n  }, [])\n\n  return reduced\n}\n\n/**\n * A photo that lifts off the page at full size over a blurred backdrop.\n *\n * The photo you click and the photo you end up looking at are the same\n * element. It is measured where it sits, drawn at its final size, and put back\n * down onto the page with a transform. Anything else is two photos pretending\n * to be one, and the pretence shows the moment either of them is a frame late.\n */\nexport function PhotoStamp({\n  src,\n  alt,\n  caption,\n  radius = 10,\n  fill = 0.72,\n  spring = { duration: 0.5, bounce: 0 },\n  className,\n  imageClassName,\n  ...props\n}: PhotoStampProps) {\n  const [mounted, setMounted] = React.useState(false)\n  const [phase, setPhase] = React.useState<\"open\" | \"closed\">(\"closed\")\n  const reduced = usePrefersReducedMotion()\n\n  const thumb = React.useRef<HTMLImageElement>(null)\n  const photo = React.useRef<HTMLImageElement>(null)\n  const dialog = React.useRef<HTMLDivElement>(null)\n  const closer = React.useRef<HTMLButtonElement>(null)\n  const restore = React.useRef<HTMLElement | null>(null)\n  const geometry = React.useRef<Geometry | null>(null)\n  const closing = React.useRef(false)\n\n  const room = caption ? CAPTION_ROOM : 0\n\n  /**\n   * The radius is written per frame rather than transitioned. A scaled box\n   * scales its corners with it, so a radius left alone arrives several times\n   * too round and shrinks back down over the course of the lift. Dividing by\n   * the scale the photo is at right now holds the corner at one size in screen\n   * pixels, and leaves the transform as the only thing describing the travel.\n   */\n  const paint = React.useCallback(\n    ({ p }: Record<string, number>) => {\n      const node = photo.current\n      const geo = geometry.current\n      if (!node || !geo) return\n\n      const scale = geo.scale + (1 - geo.scale) * p\n      const x = (geo.from.x - geo.to.x) * (1 - p)\n      const y = (geo.from.y - geo.to.y) * (1 - p)\n\n      node.style.transform = `translate3d(${x}px, ${y}px, 0) scale(${scale})`\n      node.style.borderRadius = `${radius / scale}px`\n\n      if (closing.current && p <= 0.0005) {\n        closing.current = false\n        geometry.current = null\n        setMounted(false)\n      }\n    },\n    [radius]\n  )\n\n  // An exit runs quicker than the entrance it undoes. Retargeting the spring's\n  // stiffness mid-flight keeps whatever velocity the photo already had, so a\n  // close that interrupts an open never stops to start again.\n  const travel = React.useMemo<SpringOptions>(\n    () =>\n      phase === \"closed\"\n        ? { ...spring, duration: (spring.duration ?? 0.5) * EXIT_RATE }\n        : spring,\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n    [phase, spring.duration, spring.bounce]\n  )\n\n  const { to, set } = useSpring(paint, travel)\n\n  const read = React.useCallback((): Geometry | null => {\n    const node = thumb.current\n    if (!node) return null\n\n    const from = measure(node)\n    const box = fit(from, fill, room)\n\n    return { from, to: box, scale: from.width / box.width }\n  }, [fill, room])\n\n  const open = React.useCallback(() => {\n    if (mounted) return\n\n    const next = read()\n    if (!next) return\n\n    geometry.current = next\n    closing.current = false\n    restore.current = document.activeElement as HTMLElement | null\n\n    setPhase(\"open\")\n    setMounted(true)\n  }, [mounted, read])\n\n  const finish = React.useCallback(() => {\n    closing.current = false\n    geometry.current = null\n    setMounted(false)\n  }, [])\n\n  /**\n   * `fromKeyboard` decides whether focus goes back to the photo.\n   *\n   * Sending it back is right for someone who pressed Escape: they need\n   * somewhere to carry on tabbing from, and the focus ring that comes with it\n   * is the point. It is wrong for someone who tapped, who gets a ring drawn\n   * around a photo they touched and no way to explain it. WebKit resolves\n   * `:focus-visible` on a programmatic focus from the modality it saw last,\n   * and on a phone it guesses wrong often enough to be a bug.\n   */\n  const close = React.useCallback(\n    (fromKeyboard = false) => {\n      if (!mounted || closing.current) return\n\n      // Remeasure the resting photo before aiming at it. That photo is what the\n      // page will be showing a moment later, so the landing has to be where it\n      // is now rather than where it was when this opened.\n      const geo = geometry.current\n      const node = thumb.current\n      if (geo && node) {\n        const from = measure(node)\n        geometry.current = { ...geo, from, scale: from.width / geo.to.width }\n      }\n\n      closing.current = true\n      setPhase(\"closed\")\n\n      // Restoring focus is allowed to move the page by default. On the last\n      // frame of a close, the one frame that has to be still, it must not.\n      if (fromKeyboard) restore.current?.focus?.({ preventScroll: true })\n\n      if (reduced) {\n        finish()\n        return\n      }\n\n      to({ p: 0 })\n    },\n    [finish, mounted, reduced, to]\n  )\n\n  // Start on the page, then travel. Written before paint, so there is never a\n  // frame with the photo on screen at full size in the wrong place.\n  React.useLayoutEffect(() => {\n    if (!mounted) return\n\n    if (reduced) {\n      set({ p: 1 })\n      return\n    }\n\n    set({ p: 0 })\n    to({ p: 1 })\n  }, [mounted, reduced, set, to])\n\n  React.useEffect(() => {\n    if (!mounted) return\n    dialog.current?.focus({ preventScroll: true })\n  }, [mounted])\n\n  /**\n   * Hold the page still without taking its scrollbar away.\n   *\n   * The usual lock sets `overflow: hidden` on the root. That removes the\n   * scrollbar, the viewport gets wider by its width, and everything positioned\n   * against the viewport moves. It all moves back when the lock is released,\n   * which happens on the last frame of the close: the single frame in the whole\n   * interaction that has to be perfectly still. The photo lands into a page\n   * that is jumping sideways, and the landing reads as a snap.\n   *\n   * Blocking the gestures instead leaves the layout untouched from the first\n   * frame to the last. Nothing is measured, nothing is compensated, nothing\n   * moves.\n   */\n  React.useEffect(() => {\n    if (!mounted) return\n\n    const block = (event: Event) => event.preventDefault()\n\n    // React attaches `wheel` and `touchmove` passively at the root, so an\n    // `onWheel` prop cannot refuse them. These have to be native listeners.\n    window.addEventListener(\"wheel\", block, { passive: false })\n    window.addEventListener(\"touchmove\", block, { passive: false })\n\n    return () => {\n      window.removeEventListener(\"wheel\", block)\n      window.removeEventListener(\"touchmove\", block)\n    }\n  }, [mounted])\n\n  // A resize moves both ends of the journey. Remeasure and repaint in place.\n  React.useEffect(() => {\n    if (!mounted) return\n\n    const remeasure = () => {\n      const next = read()\n      const node = photo.current\n      if (!next || !node) return\n\n      geometry.current = next\n      node.style.left = `${next.to.x}px`\n      node.style.top = `${next.to.y}px`\n      node.style.width = `${next.to.width}px`\n      node.style.height = `${next.to.height}px`\n      set({ p: closing.current ? 0 : 1 })\n    }\n\n    window.addEventListener(\"resize\", remeasure)\n    return () => window.removeEventListener(\"resize\", remeasure)\n  }, [mounted, read, set])\n\n  const geo = geometry.current\n\n  return (\n    <>\n      <figure\n        data-slot=\"photo-stamp\"\n        className={cn(\"flex w-fit flex-col items-center gap-2\", className)}\n        {...props}\n      >\n        <button\n          type=\"button\"\n          onClick={open}\n          aria-haspopup=\"dialog\"\n          data-open={mounted}\n          style={{\n            WebkitTapHighlightColor: \"transparent\",\n            borderRadius: `${radius}px`,\n          }}\n          className=\"focus-visible:outline-ring group block focus-visible:outline-2 focus-visible:outline-offset-4\"\n        >\n          {/* The shadow is the only thing hover changes. Anything that moved\n              the photo would change what `getBoundingClientRect` reports, and\n              the lift would start from a size the page is not showing. */}\n          <img\n            ref={thumb}\n            src={src}\n            alt={alt}\n            draggable={false}\n            style={{\n              borderRadius: `${radius}px`,\n              visibility: mounted ? \"hidden\" : undefined,\n            }}\n            className={cn(\n              \"ease-out-quart block h-24 w-32 object-cover shadow-[0_2px_6px_rgb(0_0_0/0.18)] transition-shadow duration-180 select-none group-hover:shadow-[0_4px_14px_rgb(0_0_0/0.24)] motion-reduce:transition-none\",\n              imageClassName\n            )}\n          />\n        </button>\n        {caption ? (\n          <figcaption className=\"text-muted-foreground text-xs\">\n            {caption}\n          </figcaption>\n        ) : null}\n      </figure>\n\n      {mounted && geo\n        ? createPortal(\n            <div\n              ref={dialog}\n              role=\"dialog\"\n              aria-modal=\"true\"\n              aria-label={alt}\n              tabIndex={-1}\n              onKeyDown={(event) => {\n                if (event.key === \"Escape\") close(true)\n                // One thing to land on, so tabbing anywhere lands on it.\n                if (event.key === \"Tab\") {\n                  event.preventDefault()\n                  closer.current?.focus()\n                }\n                if (SCROLL_KEYS.has(event.key)) event.preventDefault()\n              }}\n              className=\"fixed inset-0 z-50 outline-none\"\n            >\n              <div\n                data-state={phase}\n                onClick={() => close()}\n                className=\"bg-background/70 data-[state=closed]:animate-photo-stamp-veil-out data-[state=open]:animate-photo-stamp-veil-in absolute inset-0 backdrop-blur-xl motion-reduce:animate-none\"\n              />\n              <img\n                ref={photo}\n                src={src}\n                alt=\"\"\n                draggable={false}\n                onClick={() => close()}\n                style={{\n                  left: `${geo.to.x}px`,\n                  top: `${geo.to.y}px`,\n                  width: `${geo.to.width}px`,\n                  height: `${geo.to.height}px`,\n                  borderRadius: `${radius}px`,\n                }}\n                className=\"fixed origin-top-left object-cover shadow-[0_2px_6px_rgb(0_0_0/0.18)] will-change-transform select-none\"\n              />\n              {caption ? (\n                <figcaption\n                  data-state={phase}\n                  style={{ top: `${geo.to.y + geo.to.height + 14}px` }}\n                  className=\"text-muted-foreground data-[state=closed]:animate-photo-stamp-veil-out data-[state=open]:animate-photo-stamp-caption-in absolute inset-x-0 text-center text-sm motion-reduce:animate-none\"\n                >\n                  {caption}\n                </figcaption>\n              ) : null}\n              <button\n                ref={closer}\n                type=\"button\"\n                onClick={() => close()}\n                aria-label=\"Close photo\"\n                data-state={phase}\n                className=\"text-muted-foreground hover:text-foreground ease-out-quart data-[state=closed]:animate-photo-stamp-veil-out data-[state=open]:animate-photo-stamp-veil-in absolute top-5 right-5 flex size-10 items-center justify-center rounded-full transition-colors duration-180 motion-reduce:animate-none\"\n              >\n                <svg\n                  viewBox=\"0 0 24 24\"\n                  fill=\"none\"\n                  stroke=\"currentColor\"\n                  strokeWidth=\"2\"\n                  strokeLinecap=\"round\"\n                  className=\"size-5\"\n                  aria-hidden\n                >\n                  <path d=\"M6 6l12 12M18 6L6 18\" />\n                </svg>\n              </button>\n            </div>,\n            document.body\n          )\n        : null}\n    </>\n  )\n}\n",
      "type": "registry:ui"
    }
  ],
  "cssVars": {
    "theme": {
      "ease-out-quart": "cubic-bezier(0.165, 0.84, 0.44, 1)",
      "animate-photo-stamp-veil-in": "photo-stamp-veil-in 240ms var(--ease-out-quart)",
      "animate-photo-stamp-veil-out": "photo-stamp-veil-out 300ms var(--ease-out-quart) forwards",
      "animate-photo-stamp-caption-in": "photo-stamp-caption-in 240ms var(--ease-out-quart) 160ms backwards"
    }
  },
  "css": {
    "@keyframes photo-stamp-veil-in": {
      "from": {
        "opacity": "0"
      }
    },
    "@keyframes photo-stamp-veil-out": {
      "to": {
        "opacity": "0"
      }
    },
    "@keyframes photo-stamp-caption-in": {
      "from": {
        "opacity": "0",
        "translate": "0 6px"
      }
    }
  }
}