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