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.

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.

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 { heading, paragraph } from "@domphy/ui";
import { type ThemeColor, themeColor, themeSpacing } from "@domphy/theme";

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 `[30, 20]`. */
  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 `150`. */
  fadeInDuration?: number;
  /** Fade-out duration in ms when a square stops being active. Defaults to `400`. */
  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 ?? [30, 20];
  const hoverColor = props.hoverColor ?? "neutral";
  const fadeInDuration = props.fadeInDuration ?? 150;
  const fadeOutDuration = props.fadeOutDuration ?? 400;

  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-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: `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;
            const scaleX = gridWidth / boundingBox.width;
            const scaleY = gridHeight / boundingBox.height;
            const localX = (event.clientX - boundingBox.left) * scaleX;
            const localY = (event.clientY - boundingBox.top) * scaleY;
            const column = Math.floor(localX / cellWidth);
            const row = Math.floor(localY / cellHeight);
            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%",
          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