---
title: Moire
description: Overlapping wave grids that drift into interference fringes.
group: Backgrounds
order: 16
new: true
---

Two soft wave lattices share a field and leave slow moire bands. Closer to print optics than particles.

```tsx
import { Moire } from "@/components/motion/moire"

export function Example() {
  return (
    <Moire
      className="h-[420px] w-full"
      color="#3b82f6"
      spacing={14}
      skew={12}
    />
  )
}
```

## Installation

```bash
npx @arctis-sh/@arctis-sh/ui@latest add moire
```

## Usage

```tsx
import { Moire } from "@/components/motion/moire"
```

```tsx
<Moire
  className="h-[420px] w-full"
  color="#3b82f6"
  spacing={14}
  skew={12}
/>
```

## API Reference

### Moire

| Prop | Type | Default |
| --- | --- | --- |
| color | string | #3b82f6 |
| backgroundColor | string | — |
| spacing | number | 14 |
| angle | number | 18 |
| skew | number | 12 |
| speed | number | 1 |
| contrast | number | 0.72 |
| opacity | number | 0.5 |
| soft | number | 2 |
| dpr | number | 1.5 |
| className | string | — |
| children | ReactNode | — |

Also accepts the other native `div` attributes. `spacing` is in pixels. `angle` and `skew` are in degrees.

## Source

```tsx
"use client";

import "@/lib/motion-styles";

import {
  useEffect,
  useRef,
  type ComponentProps,
  type CSSProperties,
  type ReactNode,
} from "react";
import { cn } from "@/lib/utils";

export type MoireProps = Omit<ComponentProps<"div">, "children" | "color"> & {
  /** Interference tint. */
  color?: string;
  /** Optional solid fill behind the field. */
  backgroundColor?: string;
  /** Line wavelength in px. Lower is denser. */
  spacing?: number;
  /** Primary grid angle in degrees. */
  angle?: number;
  /** Degrees between the two grids. */
  skew?: number;
  /** Drift rate. */
  speed?: number;
  /** How hard the fringes punch (0–1). */
  contrast?: number;
  /** Master alpha (0–1). */
  opacity?: number;
  /** Sample step in css px. Higher is cheaper and softer. */
  soft?: number;
  /** Cap for device pixel ratio. */
  dpr?: number;
  children?: ReactNode;
};

type Rgba = { r: number; g: number; b: number };

function parseColor(color: string): Rgba {
  if (typeof document === "undefined") return { r: 59, g: 130, b: 246 };
  const canvas = document.createElement("canvas");
  canvas.width = canvas.height = 1;
  const ctx = canvas.getContext("2d");
  if (!ctx) return { r: 59, g: 130, b: 246 };
  ctx.fillStyle = "#000";
  ctx.fillStyle = color;
  ctx.fillRect(0, 0, 1, 1);
  const [r = 59, g = 130, b = 246] = ctx.getImageData(0, 0, 1, 1).data;
  return { r, g, b };
}

function Moire({
  className,
  color = "#3b82f6",
  backgroundColor,
  spacing = 14,
  angle = 18,
  skew = 12,
  speed = 1,
  contrast = 0.72,
  opacity = 0.5,
  soft = 2,
  dpr: dprCap = 1.5,
  children,
  style,
  ...props
}: MoireProps) {
  const rootRef = useRef<HTMLDivElement>(null);
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const paramsRef = useRef({
    color,
    backgroundColor,
    spacing,
    angle,
    skew,
    speed,
    contrast,
    opacity,
    soft,
    dprCap,
  });

  paramsRef.current = {
    color,
    backgroundColor,
    spacing,
    angle,
    skew,
    speed,
    contrast,
    opacity,
    soft,
    dprCap,
  };

  useEffect(() => {
    const root = rootRef.current;
    const canvas = canvasRef.current;
    if (!root || !canvas) return;

    const ctx = canvas.getContext("2d", { alpha: true });
    if (!ctx) return;

    let raf = 0;
    let running = true;
    let inView = true;
    let last = performance.now();
    let time = 0;
    let colorKey = "";
    let rgba: Rgba = { r: 59, g: 130, b: 246 };
    let layoutKey = "";
    let cssW = 1;
    let cssH = 1;
    let sampleW = 1;
    let sampleH = 1;
    let image: ImageData | null = null;
    const off = document.createElement("canvas");
    const offCtx = off.getContext("2d");

    const reduced = window.matchMedia("(prefers-reduced-motion: reduce)");

    function rebuild() {
      const p = paramsRef.current;
      cssW = Math.max(root!.clientWidth, 1);
      cssH = Math.max(root!.clientHeight, 1);
      const dpr = Math.min(window.devicePixelRatio || 1, Math.max(p.dprCap, 1));
      const step = Math.max(p.soft, 1);
      sampleW = Math.max(Math.ceil(cssW / step), 1);
      sampleH = Math.max(Math.ceil(cssH / step), 1);
      canvas!.width = Math.floor(cssW * dpr);
      canvas!.height = Math.floor(cssH * dpr);
      canvas!.style.width = `${cssW}px`;
      canvas!.style.height = `${cssH}px`;
      off.width = sampleW;
      off.height = sampleH;
      image = offCtx ? offCtx.createImageData(sampleW, sampleH) : null;
      layoutKey = `${cssW}x${cssH}:${step}:${p.dprCap}`;
    }

    function ensureLayout() {
      const p = paramsRef.current;
      const next = `${Math.max(root!.clientWidth, 1)}x${Math.max(root!.clientHeight, 1)}:${Math.max(p.soft, 1)}:${p.dprCap}`;
      if (next !== layoutKey) rebuild();
    }

    function paint(now: number) {
      if (!running) return;
      ensureLayout();
      const p = paramsRef.current;
      const dt = Math.min((now - last) / 1000, 0.05);
      last = now;

      if (inView && !reduced.matches) {
        time += dt * Math.max(p.speed, 0);
      }

      if (p.color !== colorKey) {
        rgba = parseColor(p.color);
        colorKey = p.color;
      }

      const dpr = Math.min(window.devicePixelRatio || 1, Math.max(p.dprCap, 1));
      const master = Math.min(Math.max(p.opacity, 0), 1);
      const punch = Math.min(Math.max(p.contrast, 0), 1);
      const wavelength = Math.max(p.spacing, 4);
      const a1 = (p.angle * Math.PI) / 180;
      const a2 = ((p.angle + p.skew) * Math.PI) / 180;
      const c1 = Math.cos(a1);
      const s1 = Math.sin(a1);
      const c2 = Math.cos(a2);
      const s2 = Math.sin(a2);
      const freq = (Math.PI * 2) / wavelength;
      const t = reduced.matches ? 0 : time;
      const step = Math.max(p.soft, 1);

      if (!image || !offCtx) {
        rebuild();
        raf = window.requestAnimationFrame(paint);
        return;
      }

      const data = image.data;

      for (let y = 0; y < sampleH; y++) {
        const py = (y + 0.5) * step;
        for (let x = 0; x < sampleW; x++) {
          const px = (x + 0.5) * step;
          const w1 = Math.cos((px * c1 + py * s1) * freq + t * 1.15);
          const w2 = Math.cos((px * c2 + py * s2) * freq * 1.05 - t * 0.85);
          const mix = w1 * w2;
          const shaped = 0.5 + 0.5 * Math.sign(mix) * Math.pow(Math.abs(mix), 1.15);
          const fringe = 0.5 + (shaped - 0.5) * punch;
          const alpha = Math.min(Math.max(fringe * master, 0), 1);
          const i = (y * sampleW + x) * 4;
          data[i] = rgba.r;
          data[i + 1] = rgba.g;
          data[i + 2] = rgba.b;
          data[i + 3] = Math.round(alpha * 255);
        }
      }

      ctx!.setTransform(1, 0, 0, 1, 0, 0);
      ctx!.clearRect(0, 0, canvas!.width, canvas!.height);

      if (p.backgroundColor) {
        ctx!.fillStyle = p.backgroundColor;
        ctx!.fillRect(0, 0, canvas!.width, canvas!.height);
      }

      offCtx.putImageData(image, 0, 0);
      ctx!.imageSmoothingEnabled = true;
      ctx!.setTransform(dpr, 0, 0, dpr, 0, 0);
      ctx!.drawImage(off, 0, 0, cssW, cssH);

      raf = window.requestAnimationFrame(paint);
    }

    rebuild();
    last = performance.now();
    raf = window.requestAnimationFrame(paint);

    const resizeObserver = new ResizeObserver(() => rebuild());
    resizeObserver.observe(root);

    const intersectionObserver = new IntersectionObserver(
      ([entry]) => {
        inView = entry?.isIntersecting ?? true;
      },
      { threshold: 0 },
    );
    intersectionObserver.observe(canvas);

    return () => {
      running = false;
      window.cancelAnimationFrame(raf);
      resizeObserver.disconnect();
      intersectionObserver.disconnect();
    };
  }, []);

  return (
    <div
      ref={rootRef}
      data-slot="moire"
      className={cn("arctis-moire relative overflow-hidden", className)}
      style={style as CSSProperties}
      {...props}
    >
      <canvas
        ref={canvasRef}
        className="arctis-moire-canvas pointer-events-none absolute inset-0 size-full"
        aria-hidden="true"
      />
      {children ? (
        <div className="relative z-10">{children}</div>
      ) : null}
    </div>
  );
}

export { Moire };
```
