Skip to content

Animated Grid Pattern

An animated background grid pattern made with SVGs, fully customizable using Tailwind CSS.

Animated Grid Pattern

Installation

Copy and paste the following code into your project:

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

interface Square {
  id: number;
  pos: [number, number];
  iteration: number;
}

interface AnimatedGridPatternProps {
  width?: number;
  height?: number;
  x?: number;
  y?: number;
  strokeDasharray?: number;
  numSquares?: number;
  maxOpacity?: number;
  duration?: number;
  repeatDelay?: number;
  class?: string;
}

const props = withDefaults(defineProps<AnimatedGridPatternProps>(), {
  width: 40,
  height: 40,
  x: -1,
  y: -1,
  strokeDasharray: 0,
  numSquares: 50,
  maxOpacity: 0.5,
  duration: 4,
  repeatDelay: 0.5,
});

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

const containerRef = ref<SVGSVGElement | null>(null);
const dimensions = ref({ width: 0, height: 0 });
const squares = ref<Square[]>([]);

function getPos(): [number, number] {
  return [
    Math.floor((Math.random() * dimensions.value.width) / props.width),
    Math.floor((Math.random() * dimensions.value.height) / props.height),
  ];
}

function generateSquares(count: number): Square[] {
  return Array.from({ length: count }, (_, i) => ({
    id: i,
    pos: getPos(),
    iteration: 0,
  }));
}

function updateSquarePosition(squareId: number) {
  const current = squares.value[squareId];
  if (!current || current.id !== squareId) {
    return;
  }
  squares.value[squareId] = {
    ...current,
    pos: getPos(),
    iteration: current.iteration + 1,
  };
}

watch(
  () => [dimensions.value.width, dimensions.value.height, props.numSquares] as const,
  ([width, height]) => {
    if (width && height) {
      squares.value = generateSquares(props.numSquares);
    }
  },
);

function animateRect(el: Element | null, square: Square, index: number) {
  const node = el as (SVGRectElement & { animate?: SVGRectElement["animate"] }) | null;
  if (!node || typeof node.animate !== "function") {
    return;
  }

  const total = props.duration * 2 + props.repeatDelay;
  const forward = props.duration / total;
  const hold = (props.duration + props.repeatDelay) / total;

  const animation = node.animate(
    [
      { opacity: 0, offset: 0 },
      { opacity: props.maxOpacity, offset: forward },
      { opacity: props.maxOpacity, offset: hold },
      { opacity: 0, offset: 1 },
    ],
    {
      duration: total * 1000,
      delay: index * 100,
      easing: "ease-out",
      fill: "forwards",
    },
  );

  animation.onfinish = () => updateSquarePosition(square.id);
}

let resizeObserver: ResizeObserver | null = null;

onMounted(() => {
  const element = containerRef.value;
  if (!element || typeof ResizeObserver === "undefined") {
    return;
  }

  resizeObserver = new ResizeObserver((entries) => {
    for (const entry of entries) {
      const nextWidth = entry.contentRect.width;
      const nextHeight = entry.contentRect.height;
      if (dimensions.value.width !== nextWidth || dimensions.value.height !== nextHeight) {
        dimensions.value = { width: nextWidth, height: nextHeight };
      }
    }
  });

  resizeObserver.observe(element);
});

onUnmounted(() => {
  if (resizeObserver) {
    resizeObserver.disconnect();
    resizeObserver = null;
  }
});
</script>

<template>
  <svg
    ref="containerRef"
    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="props.width"
        :height="props.height"
        patternUnits="userSpaceOnUse"
        :x="props.x"
        :y="props.y"
      >
        <path
          :d="`M.5 ${props.height}V.5H${props.width}`"
          fill="none"
          :stroke-dasharray="props.strokeDasharray"
        />
      </pattern>
    </defs>
    <rect width="100%" height="100%" :fill="`url(#${id})`" />
    <svg :x="props.x" :y="props.y" class="overflow-visible">
      <rect
        v-for="(square, index) in squares"
        :key="`${square.id}-${square.iteration}`"
        :ref="(el) => animateRect(el as Element | null, square, index)"
        :width="props.width - 1"
        :height="props.height - 1"
        :x="square.pos[0] * props.width + 1"
        :y="square.pos[1] * props.height + 1"
        fill="currentColor"
        stroke-width="0"
        :style="{ opacity: 0 }"
      />
    </svg>
  </svg>
</template>

Props

PropTypeDefaultDescription
widthnumber40Width of the grid pattern cell
heightnumber40Height of the grid pattern cell
xnumber-1X offset of the pattern
ynumber-1Y offset of the pattern
strokeDasharraynumber0Stroke dash array of the grid lines
numSquaresnumber50Number of animated squares in the pattern
maxOpacitynumber0.5Maximum opacity each square animates to
durationnumber4Duration (in seconds) of a single fade direction
repeatDelaynumber0.5Delay (in seconds) before a square fades back out
classstring-Additional classes to be added to the pattern

Released under the MIT License.