Skip to content

Interactive Dot Grid

Dots scale and brighten around the cursor.

Installation

CLI

npx shadcn@latest add https://hantui-theta.vercel.app/r/dot-grid.json

Manual

  1. Install the dependencies

    npm install clsx tailwind-merge
  2. Add the shared helpers

    Components use cn() from lib/utils.ts and the motion tokens below. See Installation if you have not set them up yet.

    lib/color.ts
    export type Vec3 = [r: number, g: number, b: number];
    
    /**
     * Parses #rgb, #rrggbb or #rrggbbaa (alpha is ignored) into 0–255 channels.
     * Anything else falls back to black, so a typo never crashes a shader.
     */
    export function hexToRgb(hex: string): Vec3 {
      let value = hex.trim().replace(/^#/, "");
      if (value.length === 3 || value.length === 4) value = [...value.slice(0, 3)].map((c) => c + c).join("");
      if (!/^[\da-f]{6}([\da-f]{2})?$/i.test(value)) return [0, 0, 0];
      const n = Number.parseInt(value.slice(0, 6), 16);
      return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
    }
    
    /** Hex color as a normalized vec3 (each channel 0–1) for shader uniforms. */
    export function hexToVec3(hex: string): Vec3 {
      const [r, g, b] = hexToRgb(hex);
      return [r / 255, g / 255, b / 255];
    }
    
    /**
     * Any CSS color the browser understands (hex, rgb(), oklch(), color-mix() or a variable such as
     * `var(--primary)`) as 0–255 channels, computed in the context of `element` so theme variables
     * resolve. Hex takes a fast path; everything else is computed once per call, so cache the result
     * instead of calling this every frame. Browser only; unreadable values fall back to black.
     */
    export function cssColorToRgb(color: string, element: Element): Vec3 {
      const value = color.trim();
      if (/^#[\da-f]{3,8}$/i.test(value)) return hexToRgb(value);
      // Children of a canvas still get computed styles, but a regular parent is the safer host.
      const host = element instanceof HTMLCanvasElement ? (element.parentElement ?? document.body) : element;
      const probe = document.createElement("span");
      probe.style.cssText = "position:absolute;width:0;height:0;overflow:hidden;visibility:hidden";
      probe.style.color = value;
      host.appendChild(probe);
      const computed = getComputedStyle(probe).color;
      probe.remove();
      // A 1×1 canvas turns any computed color space (oklch, color(srgb …)) into sRGB bytes.
      const ctx = document.createElement("canvas").getContext("2d", { willReadFrequently: true });
      if (!ctx) return [0, 0, 0];
      ctx.fillStyle = "#000";
      ctx.fillStyle = computed;
      ctx.fillRect(0, 0, 1, 1);
      const [r, g, b] = ctx.getImageData(0, 0, 1, 1).data;
      return [r, g, b];
    }
    
    /**
     * Calls `onChange` when the page theme may have changed: a class or data-theme change on <html> or
     * <body> (shadcn's `.dark`, next-themes, a data-theme switch) or a change of the OS color scheme.
     * Resolved colors are cached, so re-resolve them here. Browser only; returns the unsubscribe.
     */
    export function observeTheme(onChange: () => void): () => void {
      const observer = new MutationObserver(onChange);
      const options = { attributes: true, attributeFilter: ["class", "data-theme"] };
      observer.observe(document.documentElement, options);
      if (document.body) observer.observe(document.body, options);
      const scheme = window.matchMedia("(prefers-color-scheme: dark)");
      scheme.addEventListener("change", onChange);
      return () => {
        observer.disconnect();
        scheme.removeEventListener("change", onChange);
      };
    }
    
    /** `cssColorToRgb` as a normalized vec3 (each channel 0–1) for shader uniforms. */
    export function cssColorToVec3(color: string, element: Element): Vec3 {
      const [r, g, b] = cssColorToRgb(color, element);
      return [r / 255, g / 255, b / 255];
    }
    
    const toLinear = (c: number) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
    const toGamma = (c: number) => (c <= 0.0031308 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - 0.055);
    
    /** 0–255 sRGB to OKLCH: lightness 0–1, chroma, hue in radians. */
    export function rgbToOklch([r, g, b]: Vec3): Vec3 {
      const [lr, lg, lb] = [r, g, b].map((v) => toLinear(v / 255));
      const l = Math.cbrt(0.4122214708 * lr + 0.5363325363 * lg + 0.0514459929 * lb);
      const m = Math.cbrt(0.2119034982 * lr + 0.6806995451 * lg + 0.1073969566 * lb);
      const s = Math.cbrt(0.0883024619 * lr + 0.2817188376 * lg + 0.6299787005 * lb);
      const L = 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s;
      const A = 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s;
      const B = 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s;
      return [L, Math.hypot(A, B), Math.atan2(B, A)];
    }
    
    /** OKLCH back to 0–255 sRGB, clamped into gamut. */
    export function oklchToRgb([L, C, h]: Vec3): Vec3 {
      const A = C * Math.cos(h);
      const B = C * Math.sin(h);
      const l = (L + 0.3963377774 * A + 0.2158037573 * B) ** 3;
      const m = (L - 0.1055613458 * A - 0.0638541728 * B) ** 3;
      const s = (L - 0.0894841775 * A - 1.291485548 * B) ** 3;
      const rgb = [4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s, -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s, -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s];
      return rgb.map((v) => Math.round(Math.min(1, Math.max(0, toGamma(Math.min(1, Math.max(0, v))))) * 255)) as Vec3;
    }
    
    /**
     * Mixes two 0–255 colors in OKLCH, taking the short way around the hue circle, so midpoints keep
     * their chroma instead of turning muddy the way an RGB mix does.
     */
    export function mixOklch(a: Vec3, b: Vec3, t: number): Vec3 {
      const [L1, C1, h1] = rgbToOklch(a);
      const [L2, C2, h2] = rgbToOklch(b);
      // A near-gray end has no meaningful hue: borrow the other end's.
      const ha = C1 < 0.02 ? h2 : h1;
      const hb = C2 < 0.02 ? h1 : h2;
      let dh = hb - ha;
      if (dh > Math.PI) dh -= Math.PI * 2;
      if (dh < -Math.PI) dh += Math.PI * 2;
      return oklchToRgb([L1 + (L2 - L1) * t, C1 + (C2 - C1) * t, ha + dh * t]);
    }
  3. Copy the source

    components/ui/dot-grid/dot-grid.tsx
    "use client";
    
    import { useEffect, useRef, type ComponentProps } from "react";
    import { observeTheme } from "@/lib/color";
    import { cn } from "@/lib/utils";
    
    export interface DotGridProps extends ComponentProps<"div"> {
      /** Distance between dots in px. @default 20 */
      gap?: number;
      /** Dot radius in px at rest. @default 1.4 */
      dotSize?: number;
      /** Distance in px over which the cursor's influence fades out. @default 130 */
      radius?: number;
      /** Dot scale right under the cursor. @default 2.6 */
      maxScale?: number;
      /** Resting dot color; any CSS color, including var() and color-mix(). @default "color-mix(in oklab, var(--muted-foreground) 35%, transparent)" */
      color?: string;
      /** Color dots blend toward near the cursor. @default "var(--primary)" */
      activeColor?: string;
    }
    
    type RGBA = [number, number, number, number];
    
    /** Resolves any CSS color (var(), color-mix(), oklch…) to RGBA by letting the browser paint it. */
    function resolveColor(host: HTMLElement, color: string): RGBA {
      const probe = document.createElement("span");
      probe.style.color = color;
      host.appendChild(probe);
      const computed = getComputedStyle(probe).color;
      probe.remove();
      const ctx = document.createElement("canvas").getContext("2d", { willReadFrequently: true });
      if (!ctx) return [0, 0, 0, 1];
      ctx.fillStyle = computed;
      ctx.fillRect(0, 0, 1, 1);
      const [r, g, b, a] = ctx.getImageData(0, 0, 1, 1).data;
      return [r, g, b, a / 255];
    }
    
    export function DotGrid({
      gap = 20,
      dotSize = 1.4,
      radius = 130,
      maxScale = 2.6,
      color = "color-mix(in oklab, var(--muted-foreground) 35%, transparent)",
      activeColor = "var(--primary)",
      className,
      children,
      ...props
    }: DotGridProps) {
      const rootRef = useRef<HTMLDivElement>(null);
      const canvasRef = useRef<HTMLCanvasElement>(null);
    
      useEffect(() => {
        const root = rootRef.current;
        const canvas = canvasRef.current;
        const ctx = canvas?.getContext("2d");
        if (!root || !canvas || !ctx) return;
        const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
        let width = 0;
        let height = 0;
        let cols = 0;
        let rows = 0;
        let ox = 0;
        let oy = 0;
        let influence = new Float32Array(0);
        let base: RGBA = [0, 0, 0, 0];
        let active: RGBA = [0, 0, 0, 1];
        const pointer = { x: 0, y: 0, inside: false };
        let frame = 0;
        let running = false;
    
        const draw = () => {
          ctx.clearRect(0, 0, width, height);
          let settled = true;
          // Idle dots share one path and one fill; only dots near the cursor are drawn individually.
          ctx.beginPath();
          const lit: number[] = [];
          for (let r = 0; r < rows; r++) {
            for (let c = 0; c < cols; c++) {
              const i = r * cols + c;
              const x = ox + c * gap;
              const y = oy + r * gap;
              let target = 0;
              if (pointer.inside) {
                const d = Math.hypot(x - pointer.x, y - pointer.y);
                if (d < radius) target = 0.5 + 0.5 * Math.cos((Math.PI * d) / radius);
              }
              const v = (influence[i] += (target - influence[i]) * (reduce ? 1 : 0.16));
              if (Math.abs(target - v) > 0.003) settled = false;
              if (v < 0.01) {
                ctx.moveTo(x + dotSize, y);
                ctx.arc(x, y, dotSize, 0, Math.PI * 2);
              } else lit.push(i);
            }
          }
          ctx.fillStyle = `rgba(${base[0]},${base[1]},${base[2]},${base[3]})`;
          ctx.fill();
          for (const i of lit) {
            const v = influence[i];
            const x = ox + (i % cols) * gap;
            const y = oy + Math.floor(i / cols) * gap;
            const mix = (k: number) => Math.round(base[k] + (active[k] - base[k]) * v);
            ctx.fillStyle = `rgba(${mix(0)},${mix(1)},${mix(2)},${base[3] + (active[3] - base[3]) * v})`;
            ctx.beginPath();
            ctx.arc(x, y, dotSize * (1 + (maxScale - 1) * v), 0, Math.PI * 2);
            ctx.fill();
          }
          return settled;
        };
    
        // The loop only runs while dots are still easing; at rest the canvas is static.
        const tick = () => {
          const settled = draw();
          if (settled && !pointer.inside) running = false;
          else frame = requestAnimationFrame(tick);
        };
        const start = () => {
          if (running) return;
          running = true;
          frame = requestAnimationFrame(tick);
        };
    
        const layout = () => {
          width = root.clientWidth;
          height = root.clientHeight;
          const dpr = Math.min(window.devicePixelRatio || 1, 2);
          canvas.width = Math.round(width * dpr);
          canvas.height = Math.round(height * dpr);
          ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
          cols = Math.floor(width / gap) + 1;
          rows = Math.floor(height / gap) + 1;
          ox = (width - (cols - 1) * gap) / 2;
          oy = (height - (rows - 1) * gap) / 2;
          influence = new Float32Array(cols * rows);
          base = resolveColor(root, color);
          active = resolveColor(root, activeColor);
          draw();
        };
    
        const onMove = (event: PointerEvent) => {
          const rect = root.getBoundingClientRect();
          pointer.x = event.clientX - rect.left;
          pointer.y = event.clientY - rect.top;
          pointer.inside = event.pointerType === "mouse" || event.pointerType === "pen";
          start();
        };
        const onLeave = () => {
          pointer.inside = false;
          start();
        };
    
        layout();
        const observer = new ResizeObserver(layout);
        observer.observe(root);
        // A theme switch changes what the colors resolve to.
        const unobserveTheme = observeTheme(() => {
          base = resolveColor(root, color);
          active = resolveColor(root, activeColor);
          draw();
        });
        root.addEventListener("pointermove", onMove);
        root.addEventListener("pointerleave", onLeave);
        return () => {
          cancelAnimationFrame(frame);
          observer.disconnect();
          unobserveTheme();
          root.removeEventListener("pointermove", onMove);
          root.removeEventListener("pointerleave", onLeave);
        };
      }, [gap, dotSize, radius, maxScale, color, activeColor]);
    
      return (
        <div ref={rootRef} className={cn("relative isolate overflow-hidden", className)} {...props}>
          <canvas ref={canvasRef} aria-hidden="true" className="pointer-events-none absolute inset-0 -z-10 size-full" />
          {children}
        </div>
      );
    }

Usage

import { DotGrid } from "@/components/ui/dot-grid/dot-grid";

export function Hero() {
  return (
    <DotGrid className="h-96">
      <h1>Build on a quiet grid</h1>
    </DotGrid>
  );
}

API reference

PropTypeDefaultDescription
gapnumber20Distance between dots in px.
dotSizenumber1.4Dot radius in px at rest.
radiusnumber130Distance in px over which the cursor's influence fades out.
maxScalenumber2.6Dot scale right under the cursor.
colorstring"color-mix(in oklab, var(--muted-foreground) 35%, transparent)"Resting dot color; any CSS color, including var() and color-mix().
activeColorstring"var(--primary)"Color dots blend toward near the cursor.

Also accepts every native <div> attribute, including ref and className.

  • Drawn on a canvas at device-pixel resolution. Resting dots are batched into one path, and the draw loop runs only while dots are easing, then stops.
  • Colors accept any CSS color, including var() and color-mix(), and follow your theme tokens.
  • Size it with className (the grid fills the element and stays centered).

Accessibility

  • The canvas is decorative and aria-hidden; content goes in children, above it.
  • Pointer-driven only: nothing moves on its own. With reduced motion enabled, dots respond without easing.