Skip to content

Hexagon Pattern

A background hexagon pattern made with SVGs, fully customizable using Tailwind CSS.

View source on GitHub

Installation

Copy and paste the following code into your project:

vue
<script setup lang="ts">
import { computed, useId } from "vue";
import { cn } from "@/lib/utils";

type Direction = "horizontal" | "vertical";
type HexPoint = readonly [number, number];

interface HexagonPatternProps {
  radius?: number;
  gap?: number;
  x?: number;
  y?: number;
  direction?: Direction;
  strokeDasharray?: string;
  hexagons?: Array<[number, number]>;
  class?: string;
}

const props = withDefaults(defineProps<HexagonPatternProps>(), {
  radius: 40,
  gap: 0,
  x: -1,
  y: -1,
  direction: "horizontal",
  strokeDasharray: "0",
});

const id = `hexagon-pattern-${useId()}`;

function hexVertexList(cx: number, cy: number, r: number, direction: Direction): HexPoint[] {
  const startAngle = direction === "horizontal" ? 0 : 30;
  return Array.from({ length: 6 }, (_, i) => {
    const angle = ((startAngle + i * 60) * Math.PI) / 180;
    return [cx + r * Math.cos(angle), cy + r * Math.sin(angle)] as const;
  });
}

function hexPoints(cx: number, cy: number, r: number, direction: Direction): string {
  return hexVertexList(cx, cy, r, direction)
    .map(([px, py]) => `${px},${py}`)
    .join(" ");
}

function edgeLexKey(a: HexPoint, b: HexPoint): string {
  const [p, q] = a[0] < b[0] || (a[0] === b[0] && a[1] <= b[1]) ? [a, b] : [b, a];
  return `${p[0].toFixed(6)},${p[1].toFixed(6)}|${q[0].toFixed(6)},${q[1].toFixed(6)}`;
}

function collectUniqueHexEdges(
  centers: [number, number][],
  r: number,
  direction: Direction,
): [HexPoint, HexPoint][] {
  const seen = new Set<string>();
  const edges: [HexPoint, HexPoint][] = [];
  for (const [cx, cy] of centers) {
    const verts = hexVertexList(cx, cy, r, direction);
    for (let i = 0; i < 6; i++) {
      const a = verts[i];
      const b = verts[(i + 1) % 6];
      const key = edgeLexKey(a, b);
      if (!seen.has(key)) {
        seen.add(key);
        edges.push([a, b]);
      }
    }
  }
  return edges;
}

function isSolidStrokeDasharray(strokeDasharray: string): boolean {
  const t = strokeDasharray.trim();
  return t === "" || t === "none" || t === "0";
}

function getHexSpacing(r: number, direction: Direction, gap: number) {
  const sqrt3 = Math.sqrt(3);
  if (direction === "horizontal") {
    const colStep = (3 * r) / 2 + (sqrt3 * gap) / 2;
    const rowStep = sqrt3 * r + gap;
    return { colStep, rowStep, tileW: colStep * 2, tileH: rowStep };
  }
  const colStep = sqrt3 * r + gap;
  const rowStep = (3 * r) / 2 + (sqrt3 * gap) / 2;
  return { colStep, rowStep, tileW: colStep, tileH: rowStep * 2 };
}

function getTileGeometry(r: number, direction: Direction, gap: number) {
  const { colStep, rowStep, tileW, tileH } = getHexSpacing(r, direction, gap);
  const canonical: [number, number][] =
    direction === "horizontal"
      ? [
          [colStep / 2, rowStep / 2],
          [(colStep * 3) / 2, rowStep],
        ]
      : [
          [colStep / 2, rowStep / 2],
          [colStep, (rowStep * 3) / 2],
        ];

  const centers: [number, number][] = [];
  for (const [cx, cy] of canonical) {
    centers.push([cx, cy]);
    if (cy - r < 0) centers.push([cx, cy + tileH]);
    if (cy + r > tileH) centers.push([cx, cy - tileH]);
    if (cx - r < 0) centers.push([cx + tileW, cy]);
    if (cx + r > tileW) centers.push([cx - tileW, cy]);
    if (cy - r < 0 && cx - r < 0) centers.push([cx + tileW, cy + tileH]);
    if (cy - r < 0 && cx + r > tileW) centers.push([cx - tileW, cy + tileH]);
    if (cy + r > tileH && cx - r < 0) centers.push([cx + tileW, cy - tileH]);
    if (cy + r > tileH && cx + r > tileW) centers.push([cx - tileW, cy - tileH]);
  }

  return { tileW, tileH, centers };
}

function hexCenter(
  col: number,
  row: number,
  r: number,
  direction: Direction,
  gap: number,
): [number, number] {
  const { colStep, rowStep } = getHexSpacing(r, direction, gap);
  if (direction === "horizontal") {
    const cx = col * colStep + colStep / 2;
    const cy = row * rowStep + rowStep / 2 + (col % 2 !== 0 ? rowStep / 2 : 0);
    return [cx, cy];
  }
  const cx = col * colStep + colStep / 2 + (row % 2 !== 0 ? colStep / 2 : 0);
  const cy = row * rowStep + rowStep / 2;
  return [cx, cy];
}

const geometry = computed(() => getTileGeometry(props.radius, props.direction, props.gap));
const solidStroke = computed(() => isSolidStrokeDasharray(props.strokeDasharray));
const solidPolygons = computed(() =>
  solidStroke.value
    ? geometry.value.centers.map(([cx, cy]) => ({
        key: `${cx}-${cy}`,
        points: hexPoints(cx, cy, props.radius, props.direction),
      }))
    : [],
);
const dashedEdges = computed(() =>
  solidStroke.value
    ? []
    : collectUniqueHexEdges(geometry.value.centers, props.radius, props.direction).map(([a, b]) => ({
        key: edgeLexKey(a, b),
        x1: a[0],
        y1: a[1],
        x2: b[0],
        y2: b[1],
      })),
);
const highlighted = computed(() =>
  (props.hexagons ?? []).map(([col, row]) => {
    const [cx, cy] = hexCenter(col, row, props.radius, props.direction, props.gap);
    return {
      key: `${col}-${row}`,
      points: hexPoints(cx, cy, props.radius - 1, props.direction),
    };
  }),
);
</script>

<template>
  <svg
    aria-hidden="true"
    :class="
      cn(
        'pointer-events-none absolute inset-0 h-full w-full fill-gray-400/30 stroke-gray-400/30',
        props.class,
      )
    "
  >
    <defs>
      <pattern
        :id="id"
        :width="geometry.tileW"
        :height="geometry.tileH"
        patternUnits="userSpaceOnUse"
        :x="props.x"
        :y="props.y"
      >
        <template v-if="solidStroke">
          <polygon
            v-for="poly in solidPolygons"
            :key="poly.key"
            class="fill-none"
            :points="poly.points"
            :stroke-dasharray="props.strokeDasharray"
          />
        </template>
        <template v-else>
          <line
            v-for="edge in dashedEdges"
            :key="edge.key"
            class="fill-none"
            :x1="edge.x1"
            :y1="edge.y1"
            :x2="edge.x2"
            :y2="edge.y2"
            :stroke-dasharray="props.strokeDasharray"
          />
        </template>
      </pattern>
    </defs>

    <rect width="100%" height="100%" :fill="`url(#${id})`" stroke="none" />

    <svg
      v-if="highlighted.length > 0"
      aria-hidden="true"
      class="overflow-visible"
      :x="props.x"
      :y="props.y"
    >
      <polygon
        v-for="hex in highlighted"
        :key="hex.key"
        :points="hex.points"
        stroke-width="0"
      />
    </svg>
  </svg>
</template>

Examples

Linear Gradient

View source on GitHub

Dashed Stroke

View source on GitHub

Spacing

View source on GitHub

Props

PropTypeDefaultDescription
radiusnumber40Radius from center to vertex for each hexagon
gapnumber0Extra spacing between adjacent hexagons (pixels)
xnumber-1X offset of the pattern origin
ynumber-1Y offset of the pattern origin
direction"horizontal" | "vertical""horizontal"horizontal — flat-top honeycomb; vertical — pointy-top honeycomb
hexagonsArray<[col, row]>-Grid coordinates of highlighted (filled) hexagons
strokeDasharraystring"0"SVG stroke-dasharray for the outline hexagons in the pattern
classstring-Additional classes on the root SVG (for example color utilities)

Released under the MIT License.