Skip to content
Domphy

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

PropTypeDescription
widthnumberWidth of each grid square, in SVG user units. Defaults to 40.
heightnumberHeight of each grid square, in SVG user units. Defaults to 40.
squares[number, number][columns, rows] grid dimensions. Defaults to [24, 24].
hoverColorThemeColorTheme color family for the single hovered/active square's highlight fill. Defaults to "neutral".
fadeInDurationnumberFade-in duration in ms when a square becomes active. Defaults to 100.
fadeOutDurationnumberFade-out duration in ms when a square stops being active. Defaults to 1000.
childrenDomphyElement | DomphyElement[]Foreground content layered above the pattern. Defaults to a small demo panel.
styleStyleObject
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 };

← Back to Magic UI catalog