{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "layout-grid",
  "title": "Layout Grid",
  "description": "Bento grid of image tiles that expand in place to reveal rich content.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "src/components/velora/layout-grid.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useId, useRef, useState } from \"react\";\nimport { AnimatePresence, MotionConfig, motion, useReducedMotion } from \"motion/react\";\n\nimport { cn } from \"@/lib/utils\";\n\nexport interface LayoutGridItem {\n  /** Tile title — the tile's accessible name and the expanded heading */\n  title: string;\n  /** Cover image URL */\n  src: string;\n  /** Short line under the title on the tile */\n  subtitle?: string;\n  /** Rich content shown once the tile is expanded */\n  content: React.ReactNode;\n  /** Grid placement for the tile, e.g. \"md:col-span-2\" */\n  className?: string;\n}\n\ninterface LayoutGridProps extends React.HTMLAttributes<HTMLDivElement> {\n  /** Tiles, in reading order */\n  items: LayoutGridItem[];\n}\n\n/**\n * Bento grid of image tiles. Clicking a tile morphs it into a panel that\n * fills the grid, with the other tiles dimmed behind it; Escape, Close or\n * a click outside collapses it and returns focus to the tile. The root is\n * the grid: set columns and row height with className (default 3 × 11rem).\n */\nexport function LayoutGrid({ items, className, ...props }: LayoutGridProps) {\n  const id = useId();\n  const [active, setActive] = useState<number | null>(null);\n  const tiles = useRef<(HTMLButtonElement | null)[]>([]);\n  const panel = useRef<HTMLDivElement>(null);\n  const reducedMotion = useReducedMotion();\n  const item = active === null ? null : items[active];\n\n  useEffect(() => {\n    if (active === null) return;\n    const trigger = tiles.current[active];\n    const el = panel.current;\n    el?.focus({ preventScroll: true });\n    const onKey = (e: KeyboardEvent) => e.key === \"Escape\" && setActive(null);\n    // Tiles are inert while open, so a click on one can only be the opening click\n    const onClick = ({ target }: MouseEvent) =>\n      ![el, ...tiles.current].some((node) => node?.contains(target as Node)) && setActive(null);\n    document.addEventListener(\"keydown\", onKey);\n    document.addEventListener(\"click\", onClick);\n    return () => {\n      document.removeEventListener(\"keydown\", onKey);\n      document.removeEventListener(\"click\", onClick);\n      // Return focus to the tile unless a click outside already moved it elsewhere\n      const focused = document.activeElement;\n      if (!focused || focused === document.body || el?.contains(focused)) {\n        trigger?.focus({ preventScroll: true });\n      }\n    };\n  }, [active]);\n\n  return (\n    <MotionConfig\n      transition={reducedMotion ? { duration: 0 } : { type: \"spring\", stiffness: 280, damping: 32 }}\n    >\n      <div\n        {...props}\n        data-slot=\"layout-grid\"\n        className={cn(\"relative grid w-full auto-rows-44 grid-cols-2 gap-3 md:grid-cols-3\", className)}\n      >\n        {items.map((tile, i) => (\n          <motion.button\n            key={i}\n            ref={(el) => {\n              tiles.current[i] = el;\n            }}\n            type=\"button\"\n            layoutId={`${id}-${i}`}\n            style={{ borderRadius: 16 }}\n            aria-expanded={active === i}\n            inert={active !== null || undefined}\n            onClick={() => setActive(i)}\n            className={cn(\n              \"group/tile relative flex flex-col justify-end overflow-hidden bg-muted p-4 text-left text-white transition-opacity duration-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background motion-reduce:transition-none\",\n              active !== null && \"opacity-40\",\n              tile.className\n            )}\n          >\n            <motion.img\n              layoutId={`${id}-${i}-img`}\n              src={tile.src}\n              alt=\"\"\n              loading=\"lazy\"\n              className=\"absolute inset-0 size-full object-cover transition-transform duration-500 motion-safe:group-hover/tile:scale-105\"\n            />\n            <span aria-hidden className=\"absolute inset-0 bg-linear-to-t from-black/70 via-black/10 to-transparent\" />\n            <span className=\"relative font-semibold leading-tight\">{tile.title}</span>\n            {tile.subtitle && (\n              <span className=\"relative mt-0.5 text-sm text-white/75\">{tile.subtitle}</span>\n            )}\n          </motion.button>\n        ))}\n\n        <AnimatePresence>\n          {item && (\n            <motion.div\n              key={active}\n              ref={panel}\n              layoutId={`${id}-${active}`}\n              style={{ borderRadius: 20 }}\n              role=\"region\"\n              aria-labelledby={`${id}-heading`}\n              tabIndex={-1}\n              className=\"absolute inset-2 z-10 grid overflow-hidden border bg-card text-card-foreground shadow-2xl outline-none sm:inset-6 max-md:grid-rows-[8rem_minmax(0,1fr)] md:grid-cols-[2fr_3fr] md:grid-rows-1\"\n            >\n              <div className=\"relative overflow-hidden\">\n                <motion.img\n                  layoutId={`${id}-${active}-img`}\n                  src={item.src}\n                  alt=\"\"\n                  className=\"absolute inset-0 size-full object-cover\"\n                />\n              </div>\n              <motion.div\n                initial={{ opacity: 0 }}\n                animate={{ opacity: 1, transition: { delay: reducedMotion ? 0 : 0.15 } }}\n                exit={{ opacity: 0, transition: { duration: 0 } }}\n                className=\"overflow-y-auto p-5 sm:p-7\"\n              >\n                <h3 id={`${id}-heading`} className=\"pr-10 text-xl font-semibold tracking-tight\">\n                  {item.title}\n                </h3>\n                <div className=\"mt-3 space-y-3 text-sm leading-relaxed text-muted-foreground\">\n                  {item.content}\n                </div>\n              </motion.div>\n              <button\n                type=\"button\"\n                aria-label=\"Close\"\n                onClick={() => setActive(null)}\n                className=\"absolute top-3 right-3 grid size-8 place-items-center rounded-full bg-background/80 text-foreground shadow-sm backdrop-blur hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\"\n              >\n                <svg aria-hidden viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth={2} strokeLinecap=\"round\" className=\"size-4\">\n                  <path d=\"M18 6 6 18M6 6l12 12\" />\n                </svg>\n              </button>\n            </motion.div>\n          )}\n        </AnimatePresence>\n      </div>\n    </MotionConfig>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/velora/layout-grid.tsx"
    }
  ],
  "type": "registry:ui"
}