{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "masonry-grid",
  "type": "registry:ui",
  "title": "Masonry Grid",
  "description": "A measured masonry layout with responsive column breakpoints that springs tiles into place as they are added, removed or resized.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [
    "motion"
  ],
  "files": [
    {
      "path": "registry/wancraft/masonry-grid.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { AnimatePresence, motion } from \"motion/react\";\n\ntype Spring = { stiffness: number; damping: number; mass: number };\n\n/** A fixed column count, or breakpoints keyed by minimum container width in px: { 0: 1, 480: 2, 720: 3 }. */\nexport type MasonryColumns = number | Record<number, number>;\n\nexport interface MasonryGridProps\n  extends Omit<\n    React.HTMLAttributes<HTMLElement>,\n    \"children\" | \"onDrag\" | \"onDragStart\" | \"onDragEnd\" | \"onAnimationStart\" | \"onAnimationEnd\"\n  > {\n  /** Keyed elements, one per tile. Keys must be stable: they identify tiles across reorders. */\n  children: React.ReactNode;\n  /** Column count, or breakpoints by container width. */\n  columns?: MasonryColumns;\n  /** Space between tiles, in px (both axes). */\n  gap?: number;\n  /** Animate reflow, entering and leaving tiles. */\n  animate?: boolean;\n  /** Spring for tiles moving to new positions. */\n  spring?: Spring;\n  /** Render as a list (ul > li) for collections of similar items. */\n  as?: \"div\" | \"ul\";\n}\n\nfunction subscribeReducedMotion(callback: () => void) {\n  const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n  mq.addEventListener(\"change\", callback);\n  return () => mq.removeEventListener(\"change\", callback);\n}\n\n/** SSR-safe: hydrates as \"no preference\", then follows the media query. */\nfunction usePrefersReducedMotion() {\n  return React.useSyncExternalStore(\n    subscribeReducedMotion,\n    () => window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches,\n    () => false,\n  );\n}\n\nconst DEFAULT_COLUMNS: Record<number, number> = { 0: 1, 480: 2, 720: 3, 960: 4 };\n\nfunction columnCount(columns: MasonryColumns, width: number) {\n  if (typeof columns === \"number\") return Math.max(1, Math.floor(columns));\n  let count = 1;\n  for (const [min, n] of Object.entries(columns).sort((a, b) => Number(a[0]) - Number(b[0]))) {\n    if (width >= Number(min)) count = n;\n  }\n  return Math.max(1, Math.floor(count));\n}\n\nconst round = (n: number) => Math.round(n * 100) / 100;\n\n/**\n * A masonry layout measured in JavaScript: each tile goes into the currently shortest column, so the\n * placement is stable and DOM order stays the reading order. Tiles are absolutely positioned and spring\n * to new places when tiles are added, removed or change height (ResizeObserver, e.g. once an image loads).\n */\nexport function MasonryGrid({\n  children,\n  columns = DEFAULT_COLUMNS,\n  gap = 16,\n  animate = true,\n  spring = { stiffness: 320, damping: 32, mass: 1 },\n  as = \"div\",\n  className = \"\",\n  style,\n  ...props\n}: MasonryGridProps) {\n  const reduceMotion = usePrefersReducedMotion();\n  const containerRef = React.useRef<HTMLElement>(null);\n  const tileRefs = React.useRef(new Map<string, HTMLElement>());\n  const [width, setWidth] = React.useState(0);\n  const [heights, setHeights] = React.useState<Record<string, number>>({});\n  const [settled, setSettled] = React.useState(false);\n\n  const tiles = React.Children.toArray(children).filter(React.isValidElement);\n  const keys = tiles.map((tile, i) => String(tile.key ?? i));\n  const keySignature = keys.join(\"|\");\n\n  const count = width > 0 ? columnCount(columns, width) : 1;\n  const columnWidth = width > 0 ? (width - gap * (count - 1)) / count : 0;\n\n  // Shortest-column placement in DOM order. Ties go to the leftmost column, so results are deterministic.\n  const columnHeights = new Array<number>(count).fill(0);\n  const positions = new Map<string, { x: number; y: number }>();\n  for (const key of keys) {\n    const h = heights[key];\n    if (h === undefined) continue;\n    let col = 0;\n    for (let c = 1; c < count; c++) if (columnHeights[c] < columnHeights[col] - 0.5) col = c;\n    positions.set(key, { x: round(col * (columnWidth + gap)), y: round(columnHeights[col]) });\n    columnHeights[col] += h + gap;\n  }\n  const totalHeight = Math.max(0, Math.max(...columnHeights) - gap);\n  const ready = width > 0;\n\n  const readHeights = React.useCallback(() => {\n    setHeights((prev) => {\n      let changed = false;\n      const next: Record<string, number> = {};\n      tileRefs.current.forEach((el, key) => {\n        const h = el.offsetHeight;\n        next[key] = h;\n        if (prev[key] !== h) changed = true;\n      });\n      if (Object.keys(prev).length !== Object.keys(next).length) changed = true;\n      return changed ? next : prev;\n    });\n  }, []);\n\n  // Container width drives the column count.\n  React.useEffect(() => {\n    const el = containerRef.current;\n    if (!el) return;\n    const ro = new ResizeObserver(([entry]) => {\n      const w = Math.round(entry.contentRect.width * 100) / 100;\n      setWidth((prev) => (Math.abs(prev - w) < 0.5 ? prev : w));\n    });\n    ro.observe(el);\n    return () => ro.disconnect();\n  }, []);\n\n  // Tile heights change with column width, content and late-loading images.\n  React.useEffect(() => {\n    const ro = new ResizeObserver(() => readHeights());\n    tileRefs.current.forEach((el) => ro.observe(el));\n    return () => ro.disconnect();\n  }, [keySignature, readHeights]);\n\n  // Measure synchronously after a width or tile change so the first placement is painted in place.\n  React.useLayoutEffect(() => {\n    if (!ready) return;\n    readHeights();\n    // Layout can only be measured in the browser; enabling transitions after the first placement keeps it from animating in.\n    // eslint-disable-next-line react-hooks/set-state-in-effect\n    if (!settled) setSettled(true);\n  }, [ready, columnWidth, keySignature, readHeights, settled]);\n\n  const moving = animate && !reduceMotion && settled;\n  const transition = moving ? { type: \"spring\" as const, ...spring } : { duration: 0 };\n  const Root = as;\n  const Tile = as === \"ul\" ? motion.li : motion.div;\n\n  return (\n    <Root\n      ref={containerRef as React.Ref<HTMLDivElement & HTMLUListElement>}\n      className={`relative w-full ${as === \"ul\" ? \"list-none p-0\" : \"\"} ${className}`}\n      // Before the first measure (server render) tiles flow in CSS columns, so content is never hidden.\n      style={ready ? { height: totalHeight, ...style } : { columnCount: typeof columns === \"number\" ? columns : 2, columnGap: gap, ...style }}\n      {...props}\n    >\n      <AnimatePresence initial={false}>\n        {tiles.map((tile, i) => {\n          const key = keys[i];\n          const pos = positions.get(key);\n          return (\n            <Tile\n              key={key}\n              ref={(node: HTMLElement | null) => {\n                if (node) tileRefs.current.set(key, node);\n                else tileRefs.current.delete(key);\n              }}\n              initial={moving ? { opacity: 0, scale: 0.94 } : false}\n              animate={{ opacity: 1, scale: 1, x: pos?.x ?? 0, y: pos?.y ?? 0 }}\n              exit={moving ? { opacity: 0, scale: 0.94, transition: { duration: 0.18 } } : { opacity: 0, transition: { duration: 0 } }}\n              transition={transition}\n              style={\n                ready\n                  ? { position: \"absolute\", top: 0, left: 0, width: columnWidth, visibility: pos ? \"visible\" : \"hidden\" }\n                  : { breakInside: \"avoid\", marginBottom: gap }\n              }\n            >\n              {tile}\n            </Tile>\n          );\n        })}\n      </AnimatePresence>\n    </Root>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "cards"
  ],
  "docs": "Documentation: https://wancraft.my/components/masonry-grid"
}
