{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "isometric-grid",
  "type": "registry:ui",
  "title": "Isometric Grid",
  "description": "A CSS-3D isometric field of blocks that rise and glow near the pointer — or a bar-chart city.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [],
  "files": [
    {
      "path": "registry/wancraft/isometric-grid.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\nexport interface IsometricGridProps extends Omit<React.ComponentPropsWithoutRef<\"div\">, \"children\"> {\n  /** Rows in proximity mode (data mode uses `heights.length`). */\n  rows?: number;\n  /** Columns in proximity mode (data mode uses the longest row of `heights`). */\n  cols?: number;\n  /** Footprint of one cell, in px (before responsive scaling). */\n  blockSize?: number;\n  /** Gap between blocks, in px. */\n  gap?: number;\n  /** Proximity mode: how high blocks rise. Data mode: height of the largest value. In px. */\n  maxHeight?: number;\n  /** Proximity falloff radius, in cells. */\n  radius?: number;\n  /** Highlight and high-value colour. */\n  color?: string;\n  /** Low-value tint in data mode and the colour of the proximity halo. */\n  secondaryColor?: string;\n  /** Data mode: a value per cell, rendered as an isometric bar chart. */\n  heights?: number[][];\n  /** Data mode row names, used for the axis labels and each cell's accessible name. */\n  rowLabels?: string[];\n  /** Data mode column names. */\n  colLabels?: string[];\n  /** Formats a value for labels and accessible names. */\n  formatValue?: (value: number) => string;\n  /** Draw axis labels on the ground plane (data mode) and the live readout. */\n  showLabels?: boolean;\n  /** Accessible name of the grid. */\n  label?: string;\n}\n\n/* ----------------------------- Reduced motion ------------------------------ */\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\nfunction usePrefersReducedMotion() {\n  return React.useSyncExternalStore(\n    subscribeReducedMotion,\n    () => window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches,\n    () => false,\n  );\n}\n\n/* -------------------------------- Constants -------------------------------- */\n\nconst TILT = 55; // rotateX, degrees\nconst COS_TILT = Math.cos((TILT * Math.PI) / 180);\nconst SIN_TILT = Math.sin((TILT * Math.PI) / 180);\nconst UNIT = 100; // side faces are UNIT px tall and scaled on Z, so rising never triggers layout\nconst BASE = 6; // resting block height in px\nconst clamp = (n: number, min: number, max: number) => Math.min(max, Math.max(min, n));\nconst smooth = (t: number) => t * t * (3 - 2 * t);\n\n/**\n * A CSS-3D isometric field of blocks on a `rotateX(55deg) rotateZ(45deg)` plane. Each block is a\n * top face and two visible side faces grouped under one `scale3d` on Z, so heights animate on\n * the compositor. Pointer proximity (resolved from the hovered face, or by inverting the\n * projection over gaps) raises and tints nearby blocks through CSS variables — no re-renders.\n * Pass `heights` for an isometric bar-chart city. The grid is an ARIA grid with a roving\n * tabindex: arrow keys move a raised, labelled focus block.\n */\nexport function IsometricGrid({\n  rows = 8,\n  cols = 8,\n  blockSize = 40,\n  gap = 4,\n  maxHeight = 64,\n  radius = 2.5,\n  color = \"#FF7448\",\n  secondaryColor = \"#B6F3D1\",\n  heights,\n  rowLabels,\n  colLabels,\n  formatValue = (v) => String(Math.round(v * 100) / 100),\n  showLabels = true,\n  label = \"Isometric grid\",\n  className = \"\",\n  style,\n  ...props\n}: IsometricGridProps) {\n  const reduceMotion = usePrefersReducedMotion();\n  const dataMode = Array.isArray(heights) && heights.length > 0;\n  const R = dataMode ? heights.length : clamp(Math.round(rows), 1, 40);\n  const C = dataMode ? Math.max(1, ...heights.map((r) => r.length)) : clamp(Math.round(cols), 1, 40);\n  const maxValue = dataMode ? Math.max(0, ...heights.flat().filter((v) => Number.isFinite(v))) : 0;\n  const W = C * blockSize;\n  const H = R * blockSize;\n  // Rounded so server and client markup agree exactly (Math.cos/sin may differ in the last bits).\n  const projW = Math.round(((W + H) / Math.SQRT2) * 100) / 100;\n  const projH = Math.round((projW * COS_TILT + (maxHeight + 16) * SIN_TILT) * 100) / 100;\n  const groundShift = Math.round((((maxHeight + 16) * SIN_TILT) / 2) * 100) / 100;\n\n  const valueAt = (r: number, c: number) => {\n    const v = heights?.[r]?.[c];\n    return typeof v === \"number\" && Number.isFinite(v) ? v : 0;\n  };\n  const restHeight = (r: number, c: number) =>\n    dataMode ? BASE + (maxValue > 0 ? (valueAt(r, c) / maxValue) * maxHeight : 0) : BASE;\n  const restTint = (r: number, c: number) => (dataMode && maxValue > 0 ? valueAt(r, c) / maxValue : 0);\n  const rowName = (r: number) => rowLabels?.[r] ?? `Row ${r + 1}`;\n  const colName = (c: number) => colLabels?.[c] ?? `Column ${c + 1}`;\n  const cellLabel = (r: number, c: number) =>\n    dataMode ? `${rowName(r)}, ${colName(c)}: ${formatValue(valueAt(r, c))}` : `${rowName(r)}, ${colName(c)}`;\n\n  const [active, setActive] = React.useState(0); // roving tabindex\n  const activeIndex = Math.min(active, R * C - 1);\n\n  const rootRef = React.useRef<HTMLDivElement>(null);\n  const planeRef = React.useRef<HTMLDivElement>(null);\n  const readoutRef = React.useRef<HTMLSpanElement>(null);\n  // Interaction state lives in a ref: written by handlers, read by `apply`.\n  const live = React.useRef({ hover: -1, focus: -1 });\n\n  /** Writes height and tint variables for every cell from the current hover / focus. */\n  const apply = () => {\n    const plane = planeRef.current;\n    if (!plane) return;\n    const { hover, focus } = live.current;\n    const cells = plane.querySelectorAll<HTMLElement>(\"[data-cell]\");\n    const hr = hover >= 0 ? Math.floor(hover / C) : -99;\n    const hc = hover >= 0 ? hover % C : -99;\n    cells.forEach((el, i) => {\n      const r = Math.floor(i / C);\n      const c = i % C;\n      let h = restHeight(r, c);\n      let t = restTint(r, c);\n      let halo = 0;\n      if (dataMode) {\n        if (i === hover || i === focus) {\n          h += reduceMotion ? 0 : 12;\n          halo = 1;\n        }\n      } else {\n        const d = Math.hypot(r - hr, c - hc);\n        const near = hover >= 0 ? smooth(clamp(1 - d / Math.max(0.5, radius), 0, 1)) : 0;\n        const lift = Math.max(near, i === focus ? 1 : 0);\n        if (!reduceMotion) h += lift * maxHeight;\n        t = Math.max(near * 0.85, i === focus ? 1 : 0);\n        halo = near;\n      }\n      el.style.setProperty(\"--h\", h.toFixed(1));\n      el.style.setProperty(\"--t\", t.toFixed(3));\n      el.style.setProperty(\"--halo\", halo.toFixed(3));\n    });\n    const readout = readoutRef.current;\n    if (readout) {\n      const i = hover >= 0 ? hover : focus;\n      readout.textContent = i >= 0 ? cellLabel(Math.floor(i / C), i % C) : dataMode ? \"Hover or focus a bar\" : \"Hover or focus a block\";\n    }\n  };\n\n  // Re-apply when anything that feeds `apply` changes (initial paint uses inline values).\n  React.useEffect(() => {\n    apply();\n  });\n\n  // Responsive scale: fit the projected grid inside the root.\n  React.useEffect(() => {\n    const root = rootRef.current;\n    if (!root) return;\n    const fit = () => {\n      const { width, height } = root.getBoundingClientRect();\n      const s = Math.min(1, (width - 16) / projW, (height - 16) / projH);\n      root.style.setProperty(\"--ig-scale\", Math.max(0.2, s).toFixed(4));\n    };\n    fit();\n    const ro = new ResizeObserver(fit);\n    ro.observe(root);\n    return () => ro.disconnect();\n  }, [projW, projH]);\n\n  const onPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {\n    if (e.pointerType === \"touch\") return;\n    let index = -1;\n    const cell = (e.target as Element).closest?.(\"[data-cell]\");\n    if (cell instanceof HTMLElement && planeRef.current?.contains(cell)) {\n      index = Number(cell.dataset.cell);\n    } else if (planeRef.current) {\n      // Over a gap: invert the projection of the ground plane.\n      const rect = planeRef.current.getBoundingClientRect();\n      const s = rect.width / projW;\n      const dx = (e.clientX - (rect.left + rect.width / 2)) / s;\n      const dy = (e.clientY - (rect.top + rect.height / 2)) / s / COS_TILT;\n      const u = (dx + dy) / Math.SQRT2 + W / 2;\n      const v = (dy - dx) / Math.SQRT2 + H / 2;\n      const c = Math.floor(u / blockSize);\n      const r = Math.floor(v / blockSize);\n      if (r >= 0 && r < R && c >= 0 && c < C) index = r * C + c;\n    }\n    if (index !== live.current.hover) {\n      live.current.hover = index;\n      apply();\n    }\n  };\n  const onPointerLeave = () => {\n    if (live.current.hover === -1) return;\n    live.current.hover = -1;\n    apply();\n  };\n\n  const focusCell = (index: number) => {\n    const el = planeRef.current?.querySelector<HTMLElement>(`[data-cell=\"${index}\"]`);\n    el?.focus();\n    setActive(index);\n  };\n\n  const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {\n    const r = Math.floor(activeIndex / C);\n    const c = activeIndex % C;\n    let next = -1;\n    if (e.key === \"ArrowRight\") next = r * C + Math.min(C - 1, c + 1);\n    else if (e.key === \"ArrowLeft\") next = r * C + Math.max(0, c - 1);\n    else if (e.key === \"ArrowDown\") next = Math.min(R - 1, r + 1) * C + c;\n    else if (e.key === \"ArrowUp\") next = Math.max(0, r - 1) * C + c;\n    else if (e.key === \"Home\") next = e.ctrlKey ? 0 : r * C;\n    else if (e.key === \"End\") next = e.ctrlKey ? R * C - 1 : r * C + C - 1;\n    if (next < 0) return;\n    e.preventDefault();\n    focusCell(next);\n  };\n\n  const transition = reduceMotion\n    ? \"\"\n    : \"transition-[transform,background-color] duration-300 ease-[cubic-bezier(0.22,1,0.36,1)]\";\n  const fill =\n    \"color-mix(in oklab, var(--ig-accent) calc(var(--t) * 100%), color-mix(in oklab, var(--ig-secondary) calc(var(--halo) * 30% + var(--ig-tint)), var(--ig-top)))\";\n  const faceScale = \"scale3d(1, 1, calc(var(--h) / 100))\";\n  const inner = Math.max(2, blockSize - gap);\n\n  return (\n    <div\n      ref={rootRef}\n      className={`relative flex h-full w-full items-center justify-center overflow-hidden [--ig-scale:1] [--ig-shade-a:0.1] [--ig-shade-b:0.2] [--ig-top:#ffffff] dark:[--ig-shade-a:0.3] dark:[--ig-shade-b:0.48] dark:[--ig-top:#232327] ${className}`}\n      style={\n        {\n          \"--ig-accent\": color,\n          \"--ig-secondary\": secondaryColor,\n          \"--ig-tint\": dataMode ? \"45%\" : \"0%\",\n          ...style,\n        } as React.CSSProperties\n      }\n      {...props}\n    >\n      {showLabels && (\n        <span\n          ref={readoutRef}\n          aria-hidden=\"true\"\n          className=\"pointer-events-none absolute left-3 top-3 z-10 font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-500 dark:text-zinc-400\"\n        >\n          {dataMode ? \"Hover or focus a bar\" : \"Hover or focus a block\"}\n        </span>\n      )}\n      <div\n        className=\"relative shrink-0\"\n        style={{\n          width: projW,\n          height: projH,\n          transform: \"scale(var(--ig-scale))\",\n        }}\n      >\n        <div\n          ref={planeRef}\n          role=\"grid\"\n          aria-label={label}\n          aria-rowcount={R}\n          aria-colcount={C}\n          onPointerMove={onPointerMove}\n          onPointerLeave={onPointerLeave}\n          onKeyDown={onKeyDown}\n          onFocus={(e) => {\n            const cell = (e.target as HTMLElement).dataset.cell;\n            if (cell !== undefined) {\n              live.current.focus = Number(cell);\n              apply();\n            }\n          }}\n          onBlur={(e) => {\n            if (e.relatedTarget instanceof Node && e.currentTarget.contains(e.relatedTarget)) return;\n            live.current.focus = -1;\n            apply();\n          }}\n          className=\"absolute left-1/2 top-1/2\"\n          style={{\n            width: W,\n            height: H,\n            marginLeft: -W / 2,\n            marginTop: -H / 2,\n            transformStyle: \"preserve-3d\",\n            // Push the ground down so the risen blocks stay centred.\n            transform: `translateY(${groundShift}px) rotateX(${TILT}deg) rotateZ(45deg)`,\n          }}\n        >\n          {/* Ground plate. */}\n          <div\n            aria-hidden=\"true\"\n            className=\"absolute -inset-2 rounded-[3px] border border-zinc-900/10 bg-zinc-900/[0.03] dark:border-white/10 dark:bg-white/[0.03]\"\n          />\n\n          {showLabels && dataMode && (\n            <div aria-hidden=\"true\" className=\"pointer-events-none absolute inset-0\">\n              {Array.from({ length: R }, (_, r) => (\n                <span\n                  key={`r${r}`}\n                  className=\"absolute right-full mr-4 whitespace-nowrap font-mono text-[10px] uppercase tracking-[0.1em] text-zinc-500 dark:text-zinc-400\"\n                  style={{ top: r * blockSize + blockSize / 2, transform: \"translateY(-50%)\" }}\n                >\n                  {rowName(r)}\n                </span>\n              ))}\n              {Array.from({ length: C }, (_, c) => (\n                <span\n                  key={`c${c}`}\n                  className=\"absolute bottom-full mb-4 origin-bottom-left whitespace-nowrap font-mono text-[10px] uppercase tracking-[0.1em] text-zinc-500 dark:text-zinc-400\"\n                  style={{ left: c * blockSize + blockSize / 2, transform: \"rotate(-90deg) translateY(50%)\" }}\n                >\n                  {colName(c)}\n                </span>\n              ))}\n            </div>\n          )}\n\n          {Array.from({ length: R }, (_, r) => (\n            <div\n              key={r}\n              role=\"row\"\n              aria-rowindex={r + 1}\n              className=\"pointer-events-none absolute inset-0\"\n              style={{ transformStyle: \"preserve-3d\" }}\n            >\n              {Array.from({ length: C }, (_, c) => {\n                const i = r * C + c;\n                return (\n                  <div\n                    key={c}\n                    role=\"gridcell\"\n                    aria-colindex={c + 1}\n                    aria-label={cellLabel(r, c)}\n                    data-cell={i}\n                    tabIndex={i === activeIndex ? 0 : -1}\n                    onClick={() => setActive(i)}\n                    className=\"group pointer-events-auto absolute outline-none\"\n                    style={\n                      {\n                        left: c * blockSize + gap / 2,\n                        top: r * blockSize + gap / 2,\n                        width: inner,\n                        height: inner,\n                        transformStyle: \"preserve-3d\",\n                        \"--h\": restHeight(r, c).toFixed(1),\n                        \"--t\": restTint(r, c).toFixed(3),\n                        \"--halo\": \"0\",\n                      } as React.CSSProperties\n                    }\n                  >\n                    <div\n                      aria-hidden=\"true\"\n                      className={`absolute inset-0 ${transition}`}\n                      style={{ transformStyle: \"preserve-3d\", transform: faceScale }}\n                    >\n                      {/* South face (along x, facing +y). */}\n                      <div\n                        className={`absolute left-0 w-full origin-top ${transition}`}\n                        style={{\n                          top: inner,\n                          height: UNIT,\n                          transform: \"rotateX(90deg)\",\n                          backgroundColor: fill,\n                          backgroundImage: \"linear-gradient(rgba(0,0,0,var(--ig-shade-a)), rgba(0,0,0,var(--ig-shade-a)))\",\n                        }}\n                      />\n                      {/* East face (along y, facing +x). */}\n                      <div\n                        className={`absolute top-0 h-full origin-left ${transition}`}\n                        style={{\n                          left: inner,\n                          width: UNIT,\n                          transform: \"rotateY(-90deg)\",\n                          backgroundColor: fill,\n                          backgroundImage: \"linear-gradient(rgba(0,0,0,var(--ig-shade-b)), rgba(0,0,0,var(--ig-shade-b)))\",\n                        }}\n                      />\n                    </div>\n                    {/* Top face: positioned separately so its border is never scaled. */}\n                    <div\n                      aria-hidden=\"true\"\n                      className={`absolute inset-0 rounded-[2px] border border-zinc-900/10 group-focus-visible:outline-2 group-focus-visible:outline-offset-2 group-focus-visible:outline-[var(--ig-accent)] dark:border-white/10 ${transition}`}\n                      style={{ transform: \"translateZ(calc(var(--h) * 1px))\", backgroundColor: fill }}\n                    />\n                  </div>\n                );\n              })}\n            </div>\n          ))}\n        </div>\n      </div>\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "3d"
  ],
  "docs": "Documentation: https://wancraft.my/components/isometric-grid"
}
