liten

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.

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

Example.tsx
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

PropTypeDefaultDescription
colorstring"#3ecf8e"Dot color.
cellnumber7Grid spacing in px. Smaller means denser, smaller dots.
speednumber1Flow speed. 0 freezes it.
maskbooleantrueFade the edges with a radial mask.
classNamestring-Forwarded to the canvas.
On this page0%