Interactive Dot Grid
Dots scale and brighten around the cursor.
Installation
CLI
npx shadcn@latest add https://hantui-theta.vercel.app/r/dot-grid.jsonManual
Install the dependencies
npm install clsx tailwind-mergeAdd the shared helpers
Components use
cn()fromlib/utils.tsand the motion tokens below. See Installation if you have not set them up yet.lib/color.tsexport 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]); }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
| Prop | Type | Default | Description |
|---|---|---|---|
gap | number | 20 | Distance between dots in px. |
dotSize | number | 1.4 | Dot radius in px at rest. |
radius | number | 130 | Distance in px over which the cursor's influence fades out. |
maxScale | number | 2.6 | Dot scale right under the cursor. |
color | string | "color-mix(in oklab, var(--muted-foreground) 35%, transparent)" | Resting dot color; any CSS color, including var() and color-mix(). |
activeColor | string | "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.