{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "gallery-grid",
  "type": "registry:ui",
  "title": "Gallery Grid",
  "description": "Masonry photos that blur up, select like a photo app and glide when rearranged.",
  "categories": [
    "media"
  ],
  "dependencies": [
    "@base-ui/react",
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/motion.json",
    "https://www.stealth.pm/r/use-controllable-state.json",
    "https://www.stealth.pm/r/blur-up-image.json"
  ],
  "files": [
    {
      "path": "components/ui/gallery-grid.tsx",
      "content": "\"use client\";\nimport { Checkbox } from \"@base-ui/react/checkbox\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { useId, useRef } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { ease } from \"@/lib/motion\";\nimport { useControllableState } from \"@/lib/use-controllable-state\";\nimport { BlurUpImage } from \"@/components/ui/blur-up-image\";\n\nexport type GalleryItem = {\n  id: string;\n  src: string;\n  alt: string;\n  /** Intrinsic size. The masonry is laid out from these before any image loads. */\n  width: number;\n  height: number;\n  /** A tiny version shown blurred while the image loads. */\n  placeholder?: string;\n  /** The dominant color, shown while the image loads. */\n  color?: string;\n  title?: string;\n  meta?: string;\n};\n\ntype Columns = Record<number, number>;\n\n/**\n * Shortest-column masonry, worked out for every column count up front. Positions are\n * expressed in container-query units, so the server render is already the right layout\n * at any width: nothing measures, nothing jumps, and the DOM stays in reading order.\n */\nfunction layout(items: GalleryItem[], n: number, gap: number) {\n  const heights = new Array<number>(n).fill(0); // in column widths\n  const counts = new Array<number>(n).fill(0);\n  const w = `var(--gg-w${n})`;\n  const place = items.map((item) => {\n    let c = 0;\n    // Compare real heights (column widths plus the gaps already stacked) to pick the shortest.\n    for (let i = 1; i < n; i++) if (heights[i] * 1000 + counts[i] * gap < heights[c] * 1000 + counts[c] * gap) c = i;\n    const top = `calc(${heights[c]} * ${w} + ${counts[c] * gap}px)`;\n    const left = `calc(${c} * (${w} + ${gap}px))`;\n    heights[c] += item.height / item.width;\n    counts[c] += 1;\n    return { left, top };\n  });\n  let tallest = 0;\n  for (let i = 1; i < n; i++) if (heights[i] * 1000 + counts[i] * gap > heights[tallest] * 1000 + counts[tallest] * gap) tallest = i;\n  const height = `calc(${heights[tallest]} * ${w} + ${Math.max(0, counts[tallest] - 1) * gap}px)`;\n  return { place, height };\n}\n\nexport type GalleryGridProps = Omit<React.ComponentProps<\"div\">, \"children\" | \"onSelect\"> & {\n  items: GalleryItem[];\n  /** Column count by minimum container width in px, e.g. { 0: 2, 480: 3, 720: 4 }. */\n  columns?: Columns;\n  /** Space between tiles in px. */\n  gap?: number;\n  selected?: string[];\n  defaultSelected?: string[];\n  onSelectedChange?: (ids: string[]) => void;\n  /** Keep selection mode on with nothing selected (after a \"Select\" button). Otherwise it follows the selection. */\n  selectionMode?: boolean;\n  /** Called with false when Escape asks to leave selection mode, so a forced mode can end too. */\n  onSelectionModeChange?: (on: boolean) => void;\n  /** Called when a tile is opened outside selection mode, with its image element to animate from. Without it, a press selects. */\n  onOpen?: (item: GalleryItem, index: number, tile: HTMLElement) => void;\n  /** Overlay buttons for a tile, shown on hover and focus. Use GalleryGridAction. */\n  actions?: (item: GalleryItem) => React.ReactNode;\n  /** Shown in place of the grid when there are no items. */\n  empty?: React.ReactNode;\n};\n\nexport function GalleryGrid({\n  items,\n  columns = { 0: 2, 440: 3, 720: 4 },\n  gap = 6,\n  selected: selectedProp,\n  defaultSelected = [],\n  onSelectedChange,\n  selectionMode,\n  onSelectionModeChange,\n  onOpen,\n  actions,\n  empty,\n  className,\n  onKeyDown,\n  ...rest\n}: GalleryGridProps) {\n  const [selected, setSelected] = useControllableState({ value: selectedProp, defaultValue: defaultSelected, onChange: onSelectedChange });\n  const selecting = selectionMode ?? selected.length > 0;\n  const anchor = useRef<string | null>(null);\n  const reduce = !!useReducedMotion();\n  const id = useId().replace(/[^a-zA-Z0-9]/g, \"\");\n\n  const counts = [...new Set(Object.values(columns))];\n  const layouts = Object.fromEntries(counts.map((n) => [n, layout(items, n, gap)]));\n  const breakpoints = Object.entries(columns)\n    .map(([min, n]) => [Number(min), n] as const)\n    .sort((a, b) => a[0] - b[0]);\n\n  // One rule per breakpoint swaps which precomputed position each tile reads.\n  const css = [\n    `[data-gg=\"${id}\"]{container-type:inline-size;${counts.map((n) => `--gg-w${n}:calc((100cqw - ${(n - 1) * gap}px) / ${n});`).join(\"\")}}`,\n    ...breakpoints.map(([min, n]) => {\n      const rule = `[data-gg=\"${id}\"] [data-gg-canvas]{height:var(--gg-h${n})}[data-gg=\"${id}\"] [data-gg-tile]{--gg-l:var(--gg-l${n});--gg-t:var(--gg-t${n});width:var(--gg-w${n})}`;\n      return min > 0 ? `@container (min-width:${min}px){${rule}}` : rule;\n    }),\n  ].join(\"\");\n\n  const toggle = (itemId: string, range: boolean) => {\n    if (range && anchor.current && anchor.current !== itemId) {\n      const a = items.findIndex((i) => i.id === anchor.current);\n      const b = items.findIndex((i) => i.id === itemId);\n      if (a >= 0 && b >= 0) {\n        const span = items.slice(Math.min(a, b), Math.max(a, b) + 1).map((i) => i.id);\n        setSelected([...new Set([...selected, ...span])]);\n        anchor.current = itemId;\n        return;\n      }\n    }\n    anchor.current = itemId;\n    setSelected(selected.includes(itemId) ? selected.filter((s) => s !== itemId) : [...selected, itemId]);\n  };\n\n  if (!items.length)\n    return (\n      <div className={cn(\"grid min-h-40 place-items-center rounded-xl border border-dashed border-line-2 p-6 text-center\", className)} {...rest}>\n        {empty ?? <p className=\"text-[13px] text-fg-3\">No photos yet</p>}\n      </div>\n    );\n\n  return (\n    <div\n      data-gg={id}\n      data-selecting={selecting || undefined}\n      className={cn(\"relative w-full\", className)}\n      style={Object.fromEntries(counts.map((n) => [`--gg-h${n}`, layouts[n].height])) as React.CSSProperties}\n      onKeyDown={(e) => {\n        onKeyDown?.(e);\n        if (e.defaultPrevented) return;\n        // Escape clears the selection; ⌘A / Ctrl+A selects every photo.\n        if (e.key === \"Escape\" && selecting) {\n          e.preventDefault();\n          setSelected([]);\n          onSelectionModeChange?.(false);\n        } else if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === \"a\") {\n          e.preventDefault();\n          setSelected(items.map((i) => i.id));\n        }\n      }}\n      {...rest}\n    >\n      <style href={`gg-${id}`} precedence=\"default\">\n        {css}\n      </style>\n      <ul data-gg-canvas className=\"relative\" aria-label=\"Photos\" aria-multiselectable={selecting || undefined}>\n        <AnimatePresence initial={false}>\n          {items.map((item, index) => (\n            <Tile\n              key={item.id}\n              item={item}\n              index={index}\n              style={Object.fromEntries(\n                counts.flatMap((n) => [\n                  [`--gg-l${n}`, layouts[n].place[index].left],\n                  [`--gg-t${n}`, layouts[n].place[index].top],\n                ]),\n              )}\n              selecting={selecting}\n              checked={selected.includes(item.id)}\n              reduce={reduce}\n              onToggle={toggle}\n              onOpen={onOpen}\n              actions={actions}\n            />\n          ))}\n        </AnimatePresence>\n      </ul>\n    </div>\n  );\n}\n\ntype TileProps = {\n  item: GalleryItem;\n  index: number;\n  style: Record<string, string>;\n  selecting: boolean;\n  checked: boolean;\n  reduce: boolean;\n  onToggle: (id: string, range: boolean) => void;\n  onOpen?: GalleryGridProps[\"onOpen\"];\n  actions?: GalleryGridProps[\"actions\"];\n};\n\nfunction Tile({ item, index, style, selecting, checked, reduce, onToggle, onOpen, actions }: TileProps) {\n  const imageRef = useRef<HTMLDivElement>(null);\n  const press = useRef<{ timer: number; x: number; y: number; fired: boolean } | null>(null);\n  const name = item.title ?? item.alt;\n\n  // Long-press on touch starts selecting, the way photo apps do.\n  const onPointerDown = (e: React.PointerEvent) => {\n    if (e.pointerType !== \"touch\" || selecting) return;\n    const p = { x: e.clientX, y: e.clientY, fired: false, timer: 0 };\n    p.timer = window.setTimeout(() => {\n      p.fired = true;\n      onToggle(item.id, false);\n    }, 450);\n    press.current = p;\n  };\n  const cancelPress = (e?: React.PointerEvent) => {\n    const p = press.current;\n    if (!p) return;\n    if (e && e.type === \"pointermove\" && Math.hypot(e.clientX - p.x, e.clientY - p.y) < 8) return;\n    window.clearTimeout(p.timer);\n    if (!p.fired) press.current = null;\n  };\n\n  return (\n    <motion.li\n      data-gg-tile\n      data-checked={checked || undefined}\n      initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.94 }}\n      animate={{ opacity: 1, scale: 1 }}\n      exit={reduce ? { opacity: 0, transition: { duration: 0.12 } } : { opacity: 0, scale: 0.9, transition: { duration: 0.16, ease: ease.in } }}\n      transition={{ duration: 0.24, ease: ease.out }}\n      className={cn(\n        \"group/tile absolute left-0 top-0 select-none\",\n        // Tiles glide to their new place when the column count changes or a neighbour leaves.\n        \"[translate:var(--gg-l)_var(--gg-t)] transition-[translate] duration-300 ease-in-out-quart motion-reduce:transition-none\",\n      )}\n      style={{ ...style, aspectRatio: `${item.width} / ${item.height}` }}\n    >\n      <div\n        ref={imageRef}\n        data-gallery-image={item.id}\n        className={cn(\n          \"absolute inset-0 overflow-hidden rounded-lg bg-hover\",\n          \"transition-[scale,border-radius] duration-200 ease-out-expo motion-reduce:transition-none\",\n          // Selected photos step back from the edges, so the selection reads at a glance.\n          checked && \"scale-[0.9] rounded-xl motion-reduce:scale-100\",\n        )}\n      >\n        <BlurUpImage\n          src={item.src}\n          alt={item.alt}\n          placeholder={item.placeholder}\n          color={item.color}\n          aspectRatio={`${item.width} / ${item.height}`}\n          className=\"size-full\"\n        />\n        {/* Title on hover or focus, over a short scrim that exists only for legibility. */}\n        {item.title && (\n          <div\n            aria-hidden\n            className={cn(\n              \"pointer-events-none absolute inset-x-0 bottom-0 flex flex-col bg-linear-to-t from-page/80 to-transparent px-2.5 pb-2 pt-8\",\n              \"translate-y-1 opacity-0 transition-[opacity,translate] duration-200 ease-out-expo\",\n              \"group-hover/tile:translate-y-0 group-hover/tile:opacity-100 group-focus-within/tile:translate-y-0 group-focus-within/tile:opacity-100\",\n              \"group-data-checked/tile:opacity-0 motion-reduce:translate-y-0\",\n            )}\n          >\n            <span className=\"truncate text-[12.5px] font-medium text-fg\">{item.title}</span>\n            {item.meta && <span className=\"truncate text-[11.5px] text-fg-2\">{item.meta}</span>}\n          </div>\n        )}\n      </div>\n\n      <button\n        type=\"button\"\n        role={selecting || !onOpen ? \"checkbox\" : undefined}\n        aria-checked={selecting || !onOpen ? checked : undefined}\n        aria-label={selecting || !onOpen ? name : `Open ${name}`}\n        onPointerDown={onPointerDown}\n        onPointerMove={cancelPress}\n        onPointerUp={() => cancelPress()}\n        onPointerCancel={() => cancelPress()}\n        onContextMenu={(e) => press.current?.fired && e.preventDefault()}\n        onClick={(e) => {\n          if (press.current?.fired) {\n            press.current = null;\n            return;\n          }\n          if (selecting) onToggle(item.id, e.shiftKey);\n          else if (onOpen) onOpen(item, index, imageRef.current ?? e.currentTarget);\n          // With nothing to open, a press selects instead of doing nothing.\n          else onToggle(item.id, e.shiftKey);\n        }}\n        onKeyDown={(e) => {\n          // x selects the focused photo without opening it.\n          if (e.key === \"x\" && !e.metaKey && !e.ctrlKey && !e.altKey) {\n            e.preventDefault();\n            onToggle(item.id, e.shiftKey);\n          }\n        }}\n        className={cn(\n          \"absolute inset-0 rounded-lg [-webkit-touch-callout:none]\",\n          selecting || !onOpen ? \"cursor-default\" : \"cursor-zoom-in\",\n          \"outline-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n          \"transition-[scale] duration-150 ease-out active:scale-[0.985]\",\n        )}\n      />\n\n      <Checkbox.Root\n        checked={checked}\n        onCheckedChange={(_, details) => onToggle(item.id, !!(details.event as MouseEvent | undefined)?.shiftKey)}\n        aria-label={`Select ${name}`}\n        // In selection mode the whole tile is the checkbox; this one is just its picture.\n        tabIndex={selecting ? -1 : undefined}\n        aria-hidden={selecting || undefined}\n        className={cn(\n          \"absolute left-1.5 top-1.5 grid size-[22px] place-items-center rounded-full\",\n          \"border-[1.5px] border-raised/90 bg-page/30 text-frame shadow-[var(--shadow)] backdrop-blur-sm dark:border-fg/90\",\n          \"outline-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n          \"transition-[opacity,scale,background-color,border-color] duration-150 ease-out active:scale-90\",\n          \"data-checked:border-fg data-checked:bg-fg\",\n          \"before:absolute before:-inset-2.5 before:content-['']\",\n          // Hidden until it's useful: hover, keyboard focus, selection mode, or checked.\n          \"opacity-0 group-hover/tile:opacity-100 group-focus-within/tile:opacity-100 pointer-coarse:opacity-100\",\n          selecting && \"opacity-100\",\n        )}\n      >\n        <Checkbox.Indicator keepMounted className=\"grid place-items-center\">\n          <svg width=\"12\" height=\"12\" viewBox=\"0 0 12 12\" fill=\"none\" stroke=\"currentColor\" strokeWidth={1.8} strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden>\n            <motion.path\n              d=\"M2.75 6.25 5 8.5l4.25-5\"\n              initial={false}\n              animate={{ pathLength: checked ? 1 : 0, opacity: checked ? 1 : 0 }}\n              transition={reduce ? { duration: 0 } : { pathLength: { duration: 0.22, ease: ease.out }, opacity: { duration: 0.08 } }}\n            />\n          </svg>\n        </Checkbox.Indicator>\n      </Checkbox.Root>\n\n      {actions && (\n        <div\n          className={cn(\n            \"absolute right-1.5 top-1.5 flex gap-1\",\n            \"opacity-0 transition-opacity duration-150 group-hover/tile:opacity-100 group-focus-within/tile:opacity-100 pointer-coarse:opacity-100 has-data-active:opacity-100\",\n            selecting && \"pointer-events-none opacity-0! [&_*]:invisible\",\n          )}\n        >\n          {actions(item)}\n        </div>\n      )}\n    </motion.li>\n  );\n}\n\nexport type GalleryGridActionProps = React.ComponentProps<\"button\"> & {\n  /** Names the action; also its tooltip. */\n  label: string;\n  /** Pressed state, for toggles like Favorite. */\n  active?: boolean;\n};\n\n/** A small round button for the tile overlay. */\nexport function GalleryGridAction({ label, active, className, children, ...rest }: GalleryGridActionProps) {\n  return (\n    <button\n      type=\"button\"\n      aria-label={label}\n      title={label}\n      aria-pressed={active}\n      data-active={active || undefined}\n      className={cn(\n        \"relative grid size-7 place-items-center rounded-full bg-page/60 text-fg shadow-[var(--shadow)] backdrop-blur-sm\",\n        \"outline-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n        \"transition-[background-color,scale] duration-150 ease-out hover:bg-page/85 active:scale-90\",\n        \"before:absolute before:-inset-1.5 before:content-['']\",\n        className,\n      )}\n      {...rest}\n    >\n      {children}\n    </button>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/gallery-grid"
}
