interactiveGridPattern
A Backgrounds block/component from Magic UI — clean-room reimplemented for Domphy (see methodology). Call interactiveGridPattern() with no arguments for a working demo, or edit the code below live.
Props
| Prop | Type | Description |
|---|---|---|
width | number | Width of each grid square, in SVG user units. Defaults to 40. |
height | number | Height of each grid square, in SVG user units. Defaults to 40. |
squares | [number, number] | [columns, rows] grid dimensions. Defaults to [24, 24]. |
hoverColor | ThemeColor | Theme color family for the single hovered/active square's highlight fill. Defaults to "neutral". |
fadeInDuration | number | Fade-in duration in ms when a square becomes active. Defaults to 100. |
fadeOutDuration | number | Fade-out duration in ms when a square stops being active. Defaults to 1000. |
children | DomphyElement | DomphyElement[] | Foreground content layered above the pattern. Defaults to a small demo panel. |
style | StyleObject | — |
Implementation notes
Full reimplementation of the single-active-cell hover-tracking behavior: an SVG grid of <rect> cells, each square's DOM element captured via its own _onMount into a closure-scoped array; the grid svg's _onMount attaches mousemove/mouseleave, maps pointer position through the viewBox scale factor to a column/row index, and imperatively swaps fill/transitionDuration on exactly the previous and next active <rect> (fast fade-in ~150ms, slower fade-out ~400ms, both prop-configurable) — mirrors the imperative-DOM-write convention already used by pointer() in this package, since this is a high-frequency pointer-tracking concern unsuited to reactive state. Highlight fill is computed once via themeColor(node, 'shift-9', hoverColor) (a live var(--x-n) reference, stays theme-reactive without re-invocation) rather than a hardcoded gray, so it's theme-aware per Domphy convention (spec's upstream default was a fixed gray). Base squares use fill:'transparent'/stroke:'currentColor' (near-invisible per spec) — no 'colorful' random-per-square variant implemented (out of scope; not requested as a separate export). Demo-wrapper deviation same as the other two SVG patterns above (self-sized panel; foreground content gets pointerEvents:none so mousemove still reaches the grid underneath it). Default squares=[30,20] at 40x40 cells is this port's own reasonable hero-covering default (spec left the exact count unspecified). doctor CLI: 0 diagnostics. Direct-source-diff fix (2026-07-05): Hover fade timing didn't match upstream (150ms in / 400ms out vs upstream's 100ms in / 1000ms out) — the slow 1s fade-out trail is a signature part of the feel. Aligned the defaults.
Status: ported · Reference: Magic UI original
// magicui "Interactive Grid Pattern" — clean-room reimplementation from the
// public behavior/visual spec only (no upstream source viewed or copied).
// An SVG grid of squares where the single cell under the mouse cursor fades
// to a highlighted fill as the pointer moves.
//
// Position tracking is done imperatively (direct DOM writes on every
// mousemove, matching this package's `pointer()` block) rather than through
// reactive state, since it is a high-frequency, purely visual concern. Only
// ever one square is active at a time: entering a new square fades it in
// quickly while the previously active square fades back out a touch more
// slowly, via a per-write `transitionDuration` swap on the two `<rect>`
// elements involved.
import type { DomphyElement, ElementNode, StyleObject } from "@domphy/core";
import { type ThemeColor, themeColor, themeSpacing } from "@domphy/theme";
import { heading, paragraph } from "@domphy/ui";
export interface InteractiveGridPatternProps {
/** Width of each grid square, in SVG user units. Defaults to `40`. */
width?: number;
/** Height of each grid square, in SVG user units. Defaults to `40`. */
height?: number;
/** `[columns, rows]` grid dimensions. Defaults to `[24, 24]`. */
squares?: [number, number];
/** Theme color family for the single hovered/active square's highlight fill.
* Defaults to `"neutral"`. */
hoverColor?: ThemeColor;
/** Fade-in duration in ms when a square becomes active. Defaults to `100`. */
fadeInDuration?: number;
/** Fade-out duration in ms when a square stops being active. Defaults to `1000`. */
fadeOutDuration?: number;
/** Foreground content layered above the pattern. Defaults to a small demo panel. */
children?: DomphyElement | DomphyElement[];
style?: StyleObject;
}
let interactiveGridPatternInstanceCounter = 0;
/**
* SVG grid of squares where the single cell under the mouse cursor fades to
* a highlighted fill as the pointer moves; only ever one square is active at
* a time. Call with no arguments for a working demo — hover the panel to
* light up one cell at a time.
*/
function interactiveGridPattern(
props: InteractiveGridPatternProps = {},
): DomphyElement<"div"> {
const instanceId = ++interactiveGridPatternInstanceCounter;
const cellWidth = Math.max(4, props.width ?? 40);
const cellHeight = Math.max(4, props.height ?? 40);
const [columns, rows] = props.squares ?? [24, 24];
const hoverColor = props.hoverColor ?? "neutral";
const fadeInDuration = props.fadeInDuration ?? 100;
const fadeOutDuration = props.fadeOutDuration ?? 1000;
const gridWidth = columns * cellWidth;
const gridHeight = rows * cellHeight;
// Populated by each cell's own `_onMount` — the grid's `_onMount` reads it
// once mouse tracking starts, so it must be a plain array captured by both
// closures, not a reactive value.
const cellElements: (SVGRectElement | null)[] = new Array(
columns * rows,
).fill(null);
const squareElements: DomphyElement[] = [];
for (let row = 0; row < rows; row += 1) {
for (let column = 0; column < columns; column += 1) {
const index = row * columns + column;
squareElements.push({
rect: null,
_key: `cell-${instanceId}-${index}`,
x: column * cellWidth,
y: row * cellHeight,
width: cellWidth,
height: cellHeight,
ariaHidden: "true",
_onMount: (node: ElementNode) => {
cellElements[index] = node.domElement as unknown as SVGRectElement;
},
style: {
fill: "transparent",
stroke: "currentColor",
strokeWidth: 1,
transitionProperty: "fill",
transitionDuration: `${fadeOutDuration}ms`,
transitionTimingFunction: "ease-in-out",
} as StyleObject,
} as DomphyElement);
}
}
const defaultChildren: DomphyElement[] = [
{ h3: "Interactive Grid Pattern", $: [heading()] } as DomphyElement,
{
p: "Move your pointer over the grid — one square lights up at a time.",
$: [paragraph()],
} as DomphyElement,
];
const contentChildren = props.children
? Array.isArray(props.children)
? props.children
: [props.children]
: defaultChildren;
return {
div: [
{
svg: squareElements,
// viewBox + preserveAspectRatio="none": the grid scales to fill 100%
// of the container in both axes (cells stretch slightly off-square
// when the container's aspect differs from the grid's). The previous
// fixed-pixel no-viewBox sizing matched upstream's 1:1 px mapping but
// left a hard blank strip wherever the container was larger than the
// authored grid (visual QA: right ~20% empty at wide containers).
viewBox: `0 0 ${gridWidth} ${gridHeight}`,
preserveAspectRatio: "none",
ariaHidden: "true",
_onMount: (node: ElementNode) => {
const svgElement = node.domElement as unknown as SVGSVGElement;
// themeColor() returns a live `var(--x-n)` reference — computed once
// here, it stays reactive to theme swaps without re-invocation.
const highlightFill = themeColor(node, "shift-9", hoverColor);
let activeIndex = -1;
const deactivate = (index: number) => {
const cell = cellElements[index];
if (!cell) return;
cell.style.transitionDuration = `${fadeOutDuration}ms`;
cell.style.fill = "transparent";
};
const activate = (index: number) => {
const cell = cellElements[index];
if (!cell) return;
cell.style.transitionDuration = `${fadeInDuration}ms`;
cell.style.fill = highlightFill;
};
const indexFromEvent = (event: MouseEvent): number | null => {
const boundingBox = svgElement.getBoundingClientRect();
if (boundingBox.width === 0 || boundingBox.height === 0)
return null;
// viewBox scaled to the element box: map the pointer fraction of
// the element's size onto the column/row count directly (cells
// stretch with the container, so a unit is NOT 1 CSS px here).
const localX = event.clientX - boundingBox.left;
const localY = event.clientY - boundingBox.top;
const column = Math.floor((localX / boundingBox.width) * columns);
const row = Math.floor((localY / boundingBox.height) * rows);
if (column < 0 || column >= columns || row < 0 || row >= rows)
return null;
return row * columns + column;
};
const handleMove = (event: MouseEvent) => {
const index = indexFromEvent(event);
if (index === activeIndex) return;
if (activeIndex !== -1) deactivate(activeIndex);
if (index !== null) activate(index);
activeIndex = index ?? -1;
};
const handleLeave = () => {
if (activeIndex !== -1) deactivate(activeIndex);
activeIndex = -1;
};
svgElement.addEventListener("mousemove", handleMove);
svgElement.addEventListener("mouseleave", handleLeave);
node.addHook("Remove", () => {
svgElement.removeEventListener("mousemove", handleMove);
svgElement.removeEventListener("mouseleave", handleLeave);
});
},
style: {
position: "absolute",
inset: 0,
width: "100%",
height: "100%",
border: "1px solid currentColor",
color: (listener) => themeColor(listener, "shift-3"),
} as StyleObject,
} as DomphyElement<"svg">,
{
div: contentChildren,
style: { position: "relative", zIndex: 1, pointerEvents: "none" },
},
],
dataTone: "shift-1",
style: {
position: "relative",
overflow: "hidden",
borderRadius: themeSpacing(4),
padding: themeSpacing(8),
minHeight: themeSpacing(64),
backgroundColor: (listener) => themeColor(listener, "inherit"),
color: (listener) => themeColor(listener, "shift-9"),
...(props.style ?? {}),
} as StyleObject,
};
}
export { interactiveGridPattern };