Dot Field
A canvas of dots that breathe and drift through a domain-warped noise field, deterministic and identical on every render.
Each dot's size and opacity follow a turbulent, domain-warped noise field, so the grid reads as an organic current rather than a per-frame flicker. The field is pure math, so the render is identical on server and client, and it freezes to a still frame under reduced motion.
Installation
Complete the shared Setup first, then copy the component into
components/ui/dot-field.tsx.
'use client';import * as React from 'react';import { cn } from '@/lib/cn';export type DotFieldProps = { color?: string; cell?: number; speed?: number; mask?: boolean; className?: string;};function hash(x: number, y: number) { const n = Math.sin(x * 127.1 + y * 311.7) * 43758.5453; return n - Math.floor(n);}function noise(x: number, y: number) { const xi = Math.floor(x); const yi = Math.floor(y); const xf = x - xi; const yf = y - yi; const u = xf * xf * (3 - 2 * xf); const v = yf * yf * (3 - 2 * yf); const tl = hash(xi, yi); const tr = hash(xi + 1, yi); const bl = hash(xi, yi + 1); const br = hash(xi + 1, yi + 1); return ( tl * (1 - u) * (1 - v) + tr * u * (1 - v) + bl * (1 - u) * v + br * u * v );}function field(x: number, y: number, t: number) { const qx = noise(x + 1.7 + t * 0.18, y + 9.2 + t * 0.22); const qy = noise(x + 8.3 - t * 0.16, y + 2.8 + t * 0.13); let n = noise(x + 3.6 * qx, y + 3.6 * qy); n = 0.55 * n + 0.3 * noise(x * 2.3 - t * 0.2, y * 2.3 + t * 0.1); n += 0.15 * noise(x * 4.6 + t * 0.3, y * 4.6); return n;}export function DotField({ color = '#3ecf8e', cell = 7, speed = 1, mask = true, className,}: DotFieldProps) { const canvasRef = React.useRef<HTMLCanvasElement>(null); React.useEffect(() => { const canvas = canvasRef.current; if (!canvas) return; const ctx = canvas.getContext('2d'); if (!ctx) return; const reduce = window.matchMedia( '(prefers-reduced-motion: reduce)', ).matches; let raf = 0; let w = 0; let h = 0; let dpr = 1; const freq = 0.02; const maxR = cell * 0.34; const resize = () => { const rect = canvas.getBoundingClientRect(); dpr = Math.min(window.devicePixelRatio || 1, 2); w = rect.width; h = rect.height; canvas.width = Math.max(1, Math.round(w * dpr)); canvas.height = Math.max(1, Math.round(h * dpr)); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); }; const draw = (t: number) => { ctx.clearRect(0, 0, w, h); ctx.fillStyle = color; const cols = Math.ceil(w / cell) + 1; const rows = Math.ceil(h / cell) + 1; for (let gy = 0; gy < rows; gy++) { for (let gx = 0; gx < cols; gx++) { const px = gx * cell; const py = gy * cell; let val = field(px * freq, py * freq, t); val = (val - 0.3) / 0.45; if (val <= 0.04) continue; if (val > 1) val = 1; const r = val * maxR; if (r < 0.3) continue; ctx.globalAlpha = 0.2 + 0.8 * val; ctx.beginPath(); ctx.arc(px, py, r, 0, Math.PI * 2); ctx.fill(); } } ctx.globalAlpha = 1; }; resize(); if (reduce) { draw(0); } else { let start: number | null = null; const loop = (now: number) => { if (start === null) start = now; const t = ((now - start) / 1000) * speed; draw(t); raf = requestAnimationFrame(loop); }; raf = requestAnimationFrame(loop); } const ro = new ResizeObserver(() => { resize(); if (reduce) draw(0); }); ro.observe(canvas); return () => { cancelAnimationFrame(raf); ro.disconnect(); }; }, [color, cell, speed]); return ( <canvas ref={canvasRef} aria-hidden className={cn( 'pointer-events-none h-full w-full select-none', mask && '[mask-image:radial-gradient(ellipse_88%_96%_at_50%_46%,black_34%,transparent_96%)]', className, )} /> );}Usage
import { DotField } from '@/components/ui/dot-field';
export default function Example() {
return (
<div className="relative h-72 w-full overflow-hidden rounded-xl border border-black/[0.08] bg-[#fafafa] dark:border-white/10 dark:bg-[#0a0a0b]">
<DotField />
</div>
);
}Examples
A denser, faster strip masked to a horizontal band, useful as a header divider.
A warmer, slower field with a larger cell size.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
color | string | "#3ecf8e" | Dot color. |
cell | number | 7 | Grid spacing in px. Smaller means denser, smaller dots. |
speed | number | 1 | Flow speed. 0 freezes it. |
mask | boolean | true | Fade the edges with a radial mask. |
className | string | - | Forwarded to the canvas. |