# Warp Grid Background (`warp-grid`)

> Canvas grid vertices displaced by evolving value noise — drifts continuously, or warps locally around the cursor.

- **Docs:** https://www.mellowui.com/components/warp-grid
- **Markdown:** https://www.mellowui.com/components/warp-grid.md
- **Registry:** https://www.mellowui.com/r/warp-grid.json
- **Tool prompt:** https://www.mellowui.com/api/prompt/warp-grid
- **Categories:** background, canvas, interactive, animation
- **Dependencies:** none

## AI prompt

Add a WarpGrid background component from the mellow library. It displaces canvas grid vertices with evolving value noise. Use cursor={true} for a cursor-local radial warp (rest of grid stays flat) or leave it off for continuous ambient warping. Key props: lineColor, spacing, lineWidth, strength, speed, cursor, influenceRadius.

## Install

```bash
npx shadcn@latest add https://mellowui.com/r/warp-grid.json
```

## Props

| Prop | Type | Default | Description |
|---|---|---|---|
| `lineColor` | `string` | `"rgba(var(--ink-rgb),0.15)"` | Color of the grid lines. |
| `spacing` | `number` | `40` | Grid cell size in pixels. |
| `lineWidth` | `number` | `0.6` | Stroke width of the grid lines. |
| `strength` | `number` | `28` | Maximum vertex displacement in pixels. |
| `speed` | `number` | `1` | Noise evolution speed multiplier. |
| `cursor` | `boolean` | `false` | When true, warp is radially scoped to the cursor — rest of the grid stays flat. |
| `influenceRadius` | `number` | `180` | Radius of cursor warp influence in pixels (only used when cursor={true}). |
| `className` | `string` | — | Additional CSS classes. |
| `children` | `React.ReactNode` | — | Content rendered above the grid. |

## Design notes

- Client component (`"use client"`) — required for animation and refs.
- Uses Mellow design tokens: `--ink`, `--ink-rgb`, `--background`, `--background-rgb`, `--rule`, `--font-sans`, `--font-serif`, `--font-mono`. Tokens auto-flip between light and dark themes — never hardcode colors.
- Respects `prefers-reduced-motion: reduce` where applicable.
- Self-contained — no shared utilities. Copy-paste, not a package.

## Source

```tsx
"use client";

import React, { useEffect, useRef, useState } from "react";

function useInkRgb(): string {
  const [rgb, setRgb] = useState<string>("244, 241, 236");
  useEffect(() => {
    const read = () => {
      const v = getComputedStyle(document.documentElement)
        .getPropertyValue("--ink-rgb")
        .trim();
      if (v) setRgb(v);
    };
    read();
    const obs = new MutationObserver(read);
    obs.observe(document.documentElement, {
      attributes: true,
      attributeFilter: ["class", "style", "data-theme"],
    });
    return () => obs.disconnect();
  }, []);
  return rgb;
}

export interface WarpGridProps {
  lineColor?: string;
  spacing?: number;
  lineWidth?: number;
  strength?: number;
  speed?: number;
  cursor?: boolean;
  influenceRadius?: number;
  style?: React.CSSProperties;
  className?: string;
  children?: React.ReactNode;
}

function hash(x: number, y: number): number {
  const n = Math.sin(x * 127.1 + y * 311.7) * 43758.5453;
  return n - Math.floor(n);
}

function vnoise(x: number, y: number): number {
  const ix = Math.floor(x), iy = Math.floor(y);
  const fx = x - ix, fy = y - iy;
  const ux = fx * fx * (3 - 2 * fx);
  const uy = fy * fy * (3 - 2 * fy);
  const ab = hash(ix, iy) + (hash(ix + 1, iy) - hash(ix, iy)) * ux;
  const cd = hash(ix, iy + 1) + (hash(ix + 1, iy + 1) - hash(ix, iy + 1)) * ux;
  return ab + (cd - ab) * uy;
}

function smoothstep(t: number): number {
  return t * t * (3 - 2 * t);
}

export function WarpGrid({
  lineColor,
  spacing = 40,
  lineWidth = 0.6,
  strength = 28,
  speed = 1,
  cursor = false,
  influenceRadius = 180,
  style,
  className,
  children,
}: WarpGridProps) {
  const inkRgb = useInkRgb();
  const resolvedLineColor = lineColor ?? `rgba(${inkRgb}, 0.15)`;
  const wrapperRef = useRef<HTMLDivElement>(null);
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const sizeRef = useRef({ w: 0, h: 0 });
  const cursorRef = useRef({ x: -99999, y: -99999 });

  useEffect(() => {
    if (!cursor) return;
    const wrapper = wrapperRef.current;
    if (!wrapper) return;

    const onMove = (e: MouseEvent) => {
      const rect = wrapper.getBoundingClientRect();
      cursorRef.current = { x: e.clientX - rect.left, y: e.clientY - rect.top };
    };
    const onLeave = () => {
      cursorRef.current = { x: -99999, y: -99999 };
    };

    wrapper.addEventListener("mousemove", onMove);
    wrapper.addEventListener("mouseleave", onLeave);
    return () => {
      wrapper.removeEventListener("mousemove", onMove);
      wrapper.removeEventListener("mouseleave", onLeave);
    };
  }, [cursor]);

  useEffect(() => {
    const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
    if (reduced) return;

    if (!canvasRef.current) return;
    const canvas: HTMLCanvasElement = canvasRef.current;
    const ctxOrNull = canvas.getContext("2d");
    if (!ctxOrNull) return;
    const ctx: CanvasRenderingContext2D = ctxOrNull;

    let rafId: number;
    let t = 0;

    function resize() {
      const dpr = Math.min(window.devicePixelRatio, 2);
      const rect = canvas.getBoundingClientRect();
      sizeRef.current = { w: rect.width, h: rect.height };
      canvas.width = rect.width * dpr;
      canvas.height = rect.height * dpr;
      ctx.scale(dpr, dpr);
    }

    const ro = new ResizeObserver(resize);
    ro.observe(canvas);
    resize();

    function draw() {
      const { w, h } = sizeRef.current;
      if (w === 0 || h === 0) {
        rafId = requestAnimationFrame(draw);
        return;
      }

      ctx.clearRect(0, 0, w, h);

      const noiseScale = 0.003;
      const pad = Math.ceil(strength / spacing) + 1;
      const cols = Math.ceil(w / spacing) + pad * 2 + 1;
      const rows = Math.ceil(h / spacing) + pad * 2 + 1;
      const offX = -pad * spacing;
      const offY = -pad * spacing;

      const cx = cursorRef.current.x;
      const cy = cursorRef.current.y;

      const vx: number[][] = [];
      const vy: number[][] = [];
      for (let row = 0; row < rows; row++) {
        vx[row] = [];
        vy[row] = [];
        for (let col = 0; col < cols; col++) {
          const bx = offX + col * spacing;
          const by = offY + row * spacing;
          const nx = bx * noiseScale;
          const ny = by * noiseScale;

          let s = strength;
          if (cursor) {
            const dist = Math.sqrt((bx - cx) ** 2 + (by - cy) ** 2);
            const raw = Math.max(0, 1 - dist / influenceRadius);
            s = strength * smoothstep(raw);
          }

          vx[row][col] = bx + (vnoise(nx, ny + t) * 2 - 1) * s;
          vy[row][col] = by + (vnoise(nx + 5.2, ny + 1.3 + t) * 2 - 1) * s;
        }
      }

      ctx.strokeStyle = resolvedLineColor;
      ctx.lineWidth = lineWidth;
      ctx.beginPath();

      for (let row = 0; row < rows; row++) {
        ctx.moveTo(vx[row][0], vy[row][0]);
        for (let col = 1; col < cols; col++) {
          ctx.lineTo(vx[row][col], vy[row][col]);
        }
      }

      for (let col = 0; col < cols; col++) {
        ctx.moveTo(vx[0][col], vy[0][col]);
        for (let row = 1; row < rows; row++) {
          ctx.lineTo(vx[row][col], vy[row][col]);
        }
      }

      ctx.stroke();

      t += 0.003 * speed;
      rafId = requestAnimationFrame(draw);
    }

    rafId = requestAnimationFrame(draw);
    return () => {
      cancelAnimationFrame(rafId);
      ro.disconnect();
    };
  }, [resolvedLineColor, spacing, lineWidth, strength, speed, cursor, influenceRadius]);

  return (
    <div
      ref={wrapperRef}
      className={["relative overflow-hidden", className].filter(Boolean).join(" ")}
      style={style}
    >
      <canvas
        ref={canvasRef}
        aria-hidden="true"
        className="absolute inset-0 w-full h-full pointer-events-none"
      />
      {children && (
        <div className="absolute inset-0 z-[1]">{children}</div>
      )}
    </div>
  );
}

export default WarpGrid;

```

## Demo

```tsx
"use client";

import React, { useState } from "react";
import { WarpGrid } from "../mellow/warp-grid";

type Mode = "continuous" | "cursor";

export default function WarpGridDemo() {
  const [mode, setMode] = useState<Mode>("continuous");

  return (
    <div className="w-full h-full flex flex-col">
      <div className="flex justify-start gap-[2px] px-4 py-3 border-b border-(--rule)">
        {(["continuous", "cursor"] as Mode[]).map((m) => {
          const active = mode === m;
          return (
            <button
              key={m}
              onClick={() => setMode(m)}
              className={[
                "px-3 py-[0.3rem] font-mono text-[0.6875rem] uppercase tracking-[0.16em] cursor-pointer border transition-all duration-150",
                active
                  ? "bg-(--ink) text-(--background) border-(--ink)"
                  : "bg-transparent text-[rgba(var(--ink-rgb),0.45)] border-[rgba(var(--ink-rgb),0.1)]",
              ].join(" ")}
            >
              {m}
            </button>
          );
        })}
      </div>

      <WarpGrid
        key={mode}
        cursor={mode === "cursor"}
        influenceRadius={180}
        className="w-full flex-1"
      >
        <div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 flex flex-col items-center gap-2 text-center pointer-events-none">
          {mode === "continuous" ? (
            <>
              <p className="text-[rgba(var(--ink-rgb),0.55)] text-sm">
                Curl noise vector field — strokes follow the flow
              </p>
              <p className="text-[rgba(var(--ink-rgb),0.3)] text-xs">
                Vertices drift continuously
              </p>
            </>
          ) : (
            <>
              <p className="text-[rgba(var(--ink-rgb),0.55)] text-sm">
                Move cursor to warp the grid
              </p>
              <p className="text-[rgba(var(--ink-rgb),0.3)] text-xs">
                Rest of grid stays flat
              </p>
            </>
          )}
        </div>
      </WarpGrid>
    </div>
  );
}

```
