Skip to content
Domphy

hexagonPattern

A Backgrounds block/component from Magic UI — clean-room reimplemented for Domphy (see methodology). Call hexagonPattern() with no arguments for a working demo, or edit the code below live.

Props

PropTypeDescription
radiusnumberHexagon radius, center to vertex, in SVG user units. Defaults to 40.
gapnumberExtra spacing added between adjacent hexagons, in user units. Defaults to 0.
xnumberPattern origin horizontal offset, in user units. Defaults to -1.
ynumberPattern origin vertical offset, in user units. Defaults to -1.
directionHexagonPatternDirection"horizontal" renders flat-top hexagons (flat edges up/down); "vertical" renders pointy-top hexagons (a vertex up/down). Defaults to "horizontal".
hexagonsArray<[number, number]>[column, row] coordinates of cells to render as solid highlighted hexagons, layered above the outline tile. Defaults to a small demo set.
strokeDasharraystringSwitches outline rendering from a closed polygon to per-edge dashed line segments (e.g. "4 2"). The values "0", "none", and "" count as solid and render closed polygons; any other value renders dashed per-edge lines. Defaults to "0" (solid).
colorThemeColorTheme color family for the outline stroke. Defaults to "neutral".
childrenDomphyElement | DomphyElement[]Foreground content layered above the pattern. Defaults to a small demo panel.
styleStyleObjectPassthrough style merged onto the outer demo wrapper.
Implementation notes

Full clean-room reimplementation: <svg><defs><pattern> tile containing either closed hexagon polygons (solid) or 6 per-edge <line> segments (strokeDasharray set), plus a fill-url(#pattern) <rect>, plus extra highlighted-cell <polygon>s layered on top, all using tags already namespace-safe in @domphy/core (svg/defs/pattern/polygon/line/rect/g). Own-derived single-tile hex-grid geometry (3 hexagon instances per tile: one full + two seam-straddling halves) for both flat-top ('horizontal') and pointy-top ('vertical') orientation, matching the spec's every-other-row/column half-step interlock. Stroke uses currentColor (svg root's color set via themeColor) rather than per-element themeColor calls, avoiding doctor's missing-color rule entirely for the outline tile; highlighted cells do use themeColor for fill and are _doctorDisable: missing-color (decorative, no text), matching the meteors()/dottedMap() convention already in this package. Deviation from spec: the exported factory returns a self-sized demo wrapper (dataTone shift-1 panel with default heading+paragraph foreground content, overridable via children) rather than a bare position layer with no intrinsic size — necessary so hexagonPattern() with zero args is actually visible/screenshot-able per this package's factory-function contract; callers who just want the raw pattern layer can lift the inner <svg> out. direction default ('horizontal'/flat-top) and the demo hexagons highlight set are this port's own judgment calls (spec's research note didn't state a direction default). doctor CLI: 0 diagnostics.

Status: ported · Reference: Magic UI original

// magicui "Hexagon Pattern" — clean-room reimplementation from the public
// behavior/visual spec only (no upstream source viewed or copied). A
// tileable SVG background of honeycomb-style hexagon outlines (flat-top or
// pointy-top), with optional solid-filled highlighted cells layered above
// the tile.
//
// Tiling technique: a single repeating `<pattern>` tile carries the two
// canonical honeycomb hexagons PLUS every seam-straddling copy — each
// canonical hexagon is replicated onto whichever of its 8 tile-boundary
// neighbors its body still overlaps. A `<pattern>` clips content to the tile
// (default `overflow:hidden`), so without those copies every hexagon centered
// on a seam would lose its tip-sliver on one side and the seam would show
// repeating gaps. With them, the "every other row/column offset by half a
// step" honeycomb interlock reproduces seamlessly once the pattern repeats via
// `patternUnits="userSpaceOnUse"`.

import type { DomphyElement, StyleObject } from "@domphy/core";
import { type ThemeColor, themeColor, themeSpacing } from "@domphy/theme";
import { heading, paragraph } from "@domphy/ui";

export type HexagonPatternDirection = "horizontal" | "vertical";

export interface HexagonPatternProps {
  /** Hexagon radius, center to vertex, in SVG user units. Defaults to `40`. */
  radius?: number;
  /** Extra spacing added between adjacent hexagons, in user units. Defaults to `0`. */
  gap?: number;
  /** Pattern origin horizontal offset, in user units. Defaults to `-1`. */
  x?: number;
  /** Pattern origin vertical offset, in user units. Defaults to `-1`. */
  y?: number;
  /** `"horizontal"` renders flat-top hexagons (flat edges up/down); `"vertical"` renders
   * pointy-top hexagons (a vertex up/down). Defaults to `"horizontal"`. */
  direction?: HexagonPatternDirection;
  /** `[column, row]` coordinates of cells to render as solid highlighted hexagons, layered
   * above the outline tile. Defaults to a small demo set. */
  hexagons?: Array<[number, number]>;
  /** Switches outline rendering from a closed polygon to per-edge dashed line segments
   * (e.g. `"4 2"`). The values `"0"`, `"none"`, and `""` count as solid and render closed
   * polygons; any other value renders dashed per-edge lines. Defaults to `"0"` (solid). */
  strokeDasharray?: string;
  /** Theme color family for the outline stroke. Defaults to `"neutral"`. */
  color?: ThemeColor;
  /** Foreground content layered above the pattern. Defaults to a small demo panel. */
  children?: DomphyElement | DomphyElement[];
  /** Passthrough style merged onto the outer demo wrapper. */
  style?: StyleObject;
}

interface HexPoint {
  x: number;
  y: number;
}

function hexagonVertices(
  center: HexPoint,
  radius: number,
  direction: HexagonPatternDirection,
): HexPoint[] {
  // "horizontal" = flat-top (vertex at angle 0 → left/right points); "vertical" =
  // pointy-top (vertex at angle -90 → a point straight up).
  const angleOffset = direction === "vertical" ? -90 : 0;
  return Array.from({ length: 6 }, (_unused, index) => {
    const angle = ((60 * index + angleOffset) * Math.PI) / 180;
    return {
      x: center.x + radius * Math.cos(angle),
      y: center.y + radius * Math.sin(angle),
    };
  });
}

function pointsAttribute(vertices: HexPoint[]): string {
  return vertices
    .map((vertex) => `${vertex.x.toFixed(2)},${vertex.y.toFixed(2)}`)
    .join(" ");
}

// Upstream treats "0"/"none"/"" (and the default "0") as "no dashing" and renders
// closed <polygon>s; only a real dash spec switches to per-edge <line> segments.
function isSolidStrokeDasharray(value: string): boolean {
  const trimmed = value.trim();
  return trimmed === "" || trimmed === "none" || trimmed === "0";
}

function edgeKey(a: HexPoint, b: HexPoint): string {
  const aKey = `${a.x.toFixed(2)},${a.y.toFixed(2)}`;
  const bKey = `${b.x.toFixed(2)},${b.y.toFixed(2)}`;
  return aKey < bKey ? `${aKey}|${bKey}` : `${bKey}|${aKey}`;
}

// Adjacent tile centers share an edge (they touch, not just meet at a
// vertex), so mapping every center's 6 edges independently would draw each
// shared edge twice -- visible as a doubled dash when strokeDasharray is set.
function uniqueHexEdges(
  centers: HexPoint[],
  radius: number,
  direction: HexagonPatternDirection,
): Array<[HexPoint, HexPoint]> {
  const seen = new Set<string>();
  const edges: Array<[HexPoint, HexPoint]> = [];
  for (const center of centers) {
    const vertices = hexagonVertices(center, radius, direction);
    vertices.forEach((vertex, edgeIndex) => {
      const next = vertices[(edgeIndex + 1) % vertices.length];
      const key = edgeKey(vertex, next);
      if (seen.has(key)) return;
      seen.add(key);
      edges.push([vertex, next]);
    });
  }
  return edges;
}

let hexagonPatternInstanceCounter = 0;

/**
 * Tileable SVG honeycomb of hexagon outlines, with optional solid-filled
 * highlighted cells layered above the tile. Purely decorative and static —
 * intended as a background layer behind foreground content, with pointer
 * events disabled. Call with no arguments for a working demo — a full-bleed
 * hexagon grid with a few highlighted cells behind a heading.
 */
function hexagonPattern(props: HexagonPatternProps = {}): DomphyElement<"div"> {
  const instanceId = ++hexagonPatternInstanceCounter;
  const radius = Math.max(4, props.radius ?? 40);
  const gap = props.gap ?? 0;
  const originX = props.x ?? -1;
  const originY = props.y ?? -1;
  const direction = props.direction ?? "horizontal";
  const strokeDasharray = props.strokeDasharray ?? "0";
  const color = props.color ?? "neutral";
  const hexagons = props.hexagons ?? [
    [2, 1],
    [4, 3],
    [6, 0],
  ];
  const patternId = `hexagon-pattern-${instanceId}`;

  let tileWidth: number;
  let tileHeight: number;
  let columnStep: number;
  let rowStep: number;
  let canonicalCenters: HexPoint[];

  if (direction === "horizontal") {
    // Flat-top hexagon: horizontal center-to-center column spacing is
    // 1.5*radius; vertical spacing within a column is sqrt(3)*radius. Gap is
    // the visible edge-to-edge spacing, added along each axis's shared-edge
    // normal (hence the sqrt(3)/2 factor on the column axis) rather than
    // directly on the raw x/y step, so it distributes evenly around a cell.
    // A 2-column-wide, 1-row-tall tile carries the full even/odd column offset.
    columnStep = 1.5 * radius + (Math.sqrt(3) * gap) / 2;
    rowStep = Math.sqrt(3) * radius + gap;
    tileWidth = 2 * columnStep;
    tileHeight = rowStep;
    canonicalCenters = [
      { x: columnStep / 2, y: rowStep / 2 },
      { x: 1.5 * columnStep, y: rowStep },
    ];
  } else {
    // Pointy-top hexagon: mirror of the flat-top case with rows/columns swapped.
    rowStep = 1.5 * radius + (Math.sqrt(3) * gap) / 2;
    columnStep = Math.sqrt(3) * radius + gap;
    tileWidth = columnStep;
    tileHeight = 2 * rowStep;
    canonicalCenters = [
      { x: columnStep / 2, y: rowStep / 2 },
      { x: columnStep, y: 1.5 * rowStep },
    ];
  }

  // The <pattern> clips content to the tile, so a canonical hexagon whose body
  // crosses a tile edge must be re-emitted at the mirrored position on the
  // opposite side (and diagonal corners), or that seam repeats a gap where the
  // clipped tip-sliver should be. Replicate each canonical center onto whichever
  // of its 8 boundary-neighbor tiles its body still intersects. Mirrors upstream
  // getTileGeometry (~10 centers/tile), replacing the earlier 3-instance tile
  // that dropped the horizontal (and, when vertical, the vertical) seam copies.
  const tileCenters: HexPoint[] = [];
  for (const { x: cx, y: cy } of canonicalCenters) {
    tileCenters.push({ x: cx, y: cy });
    if (cy - radius < 0) tileCenters.push({ x: cx, y: cy + tileHeight });
    if (cy + radius > tileHeight)
      tileCenters.push({ x: cx, y: cy - tileHeight });
    if (cx - radius < 0) tileCenters.push({ x: cx + tileWidth, y: cy });
    if (cx + radius > tileWidth) tileCenters.push({ x: cx - tileWidth, y: cy });
    if (cy - radius < 0 && cx - radius < 0)
      tileCenters.push({ x: cx + tileWidth, y: cy + tileHeight });
    if (cy - radius < 0 && cx + radius > tileWidth)
      tileCenters.push({ x: cx - tileWidth, y: cy + tileHeight });
    if (cy + radius > tileHeight && cx - radius < 0)
      tileCenters.push({ x: cx + tileWidth, y: cy - tileHeight });
    if (cy + radius > tileHeight && cx + radius > tileWidth)
      tileCenters.push({ x: cx - tileWidth, y: cy - tileHeight });
  }

  function cellCenter(column: number, row: number): HexPoint {
    if (direction === "horizontal") {
      const isOddColumn = ((column % 2) + 2) % 2 === 1;
      return {
        x: column * columnStep + columnStep / 2 + originX,
        y:
          row * rowStep +
          rowStep / 2 +
          (isOddColumn ? rowStep / 2 : 0) +
          originY,
      };
    }
    const isOddRow = ((row % 2) + 2) % 2 === 1;
    return {
      x:
        column * columnStep +
        columnStep / 2 +
        (isOddRow ? columnStep / 2 : 0) +
        originX,
      y: row * rowStep + rowStep / 2 + originY,
    };
  }

  const tileContent: DomphyElement[] = isSolidStrokeDasharray(strokeDasharray)
    ? tileCenters.map(
        (center, centerIndex) =>
          ({
            polygon: null,
            _key: `cell-${centerIndex}`,
            points: pointsAttribute(hexagonVertices(center, radius, direction)),
            strokeDasharray,
            style: {
              stroke: "currentColor",
              fill: "none",
              strokeWidth: 1,
            } as StyleObject,
          }) as DomphyElement,
      )
    : uniqueHexEdges(tileCenters, radius, direction).map(
        ([vertex, next], edgeIndex) =>
          ({
            line: null,
            _key: `edge-${edgeIndex}`,
            x1: vertex.x,
            y1: vertex.y,
            x2: next.x,
            y2: next.y,
            strokeDasharray,
            style: { stroke: "currentColor", strokeWidth: 1 } as StyleObject,
          }) as DomphyElement,
      );

  const highlightElements: DomphyElement[] = hexagons.map(
    ([column, row], index) =>
      ({
        polygon: null,
        _key: `highlight-${instanceId}-${index}`,
        points: pointsAttribute(
          hexagonVertices(cellCenter(column, row), radius - 1, direction),
        ),
        ariaHidden: "true",
        // Decorative highlight cell with no text of its own — exempt from the
        // missing-color contract (mirrors meteors()/dottedMap() in this package).
        _doctorDisable: "missing-color",
        // Upstream highlight polygons carry no fill of their own and inherit the
        // svg's fill-gray-400/30 — the SAME tone as the grid outlines — so they
        // read as a subtle same-color fill, not a distinct hue. Match the grid's
        // shift-3/`color`, exactly as the sibling gridPattern does for its squares.
        style: {
          fill: (listener) => themeColor(listener, "shift-3", color),
          stroke: "none",
        } as StyleObject,
      }) as DomphyElement,
  );

  const defaultChildren: DomphyElement[] = [
    { h3: "Hexagon Pattern", $: [heading()] } as DomphyElement,
    {
      p: "A tileable honeycomb of hexagon outlines with a few highlighted cells.",
      $: [paragraph()],
    } as DomphyElement,
  ];
  const contentChildren = props.children
    ? Array.isArray(props.children)
      ? props.children
      : [props.children]
    : defaultChildren;

  return {
    div: [
      {
        svg: [
          {
            defs: [
              {
                pattern: tileContent,
                id: patternId,
                width: tileWidth,
                height: tileHeight,
                patternUnits: "userSpaceOnUse",
                x: originX,
                y: originY,
              } as DomphyElement,
            ],
          } as DomphyElement,
          {
            rect: null,
            width: "100%",
            height: "100%",
            ariaHidden: "true",
            style: { fill: `url(#${patternId})` } as StyleObject,
          } as DomphyElement,
          ...highlightElements,
        ],
        ariaHidden: "true",
        style: {
          position: "absolute",
          inset: 0,
          width: "100%",
          height: "100%",
          pointerEvents: "none",
          color: (listener) => themeColor(listener, "shift-3", color),
        } as StyleObject,
      } as DomphyElement<"svg">,
      { div: contentChildren, style: { position: "relative", zIndex: 1 } },
    ],
    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 { hexagonPattern };

← Back to Magic UI catalog