Skip to main content
Back to discovery

Live preview recorded from Mellow UI; component and demo published by Mellow UI. Preview: platform recorded · rights authorized.

Warp Grid Background

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

MSource LandMellow UI

Why it stands out

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

Prompt

You are given a task to integrate a React component into your codebase.
Please verify your project has the following setup:
- shadcn/ui project structure
- Tailwind CSS v4.0
- TypeScript
- motion/react v11

If any of these are missing, provide instructions on how to setup project via shadcn CLI, install Tailwind or TypeScript.

Determine the default path for components and styles. Mellow components live in `components/mellow/`. If that folder does not exist yet, create it.

Copy-paste these files:

File location: components/warp-grid-demo.tsx

File content: "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>
  );
}



File location: components/mellow/warp-grid.tsx

File content: "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;



Usage notes:
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.

Discovery vocabulary

Related by governed terms

Continue comparing

More from Mellow UI