Particle Field
Canvas particles that repel from the cursor.
Installation
CLI
npx shadcn@latest add https://hantui-theta.vercel.app/r/particle-field.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]); }lib/random.ts/** * Seeded randomness for visuals: dot layouts, characters' moods, print glitches. The same seed * gives the same sequence on the server and in the first client frame, so nothing shifts or * mismatches on hydration. Not for anything security-related. */ /** A 32-bit hash of a string or number, to turn a `seed` prop into a generator state. */ export function hashSeed(seed: string | number): number { const text = typeof seed === "number" ? `n${seed}` : seed; // FNV-1a, then a final avalanche so short, similar seeds still diverge. let h = 0x811c9dc5; for (let i = 0; i < text.length; i++) { h ^= text.charCodeAt(i); h = Math.imul(h, 0x01000193); } h ^= h >>> 16; h = Math.imul(h, 0x7feb352d); h ^= h >>> 15; return h >>> 0; } /** mulberry32: a small, fast generator of floats in [0, 1). */ export function mulberry32(state: number): () => number { let a = state >>> 0; return () => { a = (a + 0x6d2b79f5) >>> 0; let t = a; t = Math.imul(t ^ (t >>> 15), t | 1); t ^= t + Math.imul(t ^ (t >>> 7), t | 61); return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } export interface Random { /** A float in [0, 1). */ next(): number; /** A float in [min, max). */ range(min: number, max: number): number; /** An integer from min to max, both included. */ int(min: number, max: number): number; /** True with probability `p`. */ chance(p: number): boolean; /** One item of a non-empty list. */ pick<T>(list: readonly T[]): T; /** An independent generator derived from this seed and a salt, e.g. one per word or per coin. */ fork(salt: string | number): Random; } /** A seeded generator with a few helpers on top of `mulberry32`. */ export function createRandom(seed: string | number): Random { const base = hashSeed(seed); const next = mulberry32(base); return { next, range: (min, max) => min + next() * (max - min), int: (min, max) => min + Math.floor(next() * (max - min + 1)), chance: (p) => next() < p, pick: (list) => list[Math.floor(next() * list.length)], fork: (salt) => createRandom(`${base}:${salt}`), }; }Copy the source
components/ui/particle-field/particle-field.tsx"use client"; import { useEffect, useRef, type ComponentProps } from "react"; import { observeTheme } from "@/lib/color"; import { createRandom } from "@/lib/random"; import { cn } from "@/lib/utils"; export interface ParticleFieldProps extends ComponentProps<"div"> { /** Particles per 10,000 px² (capped at 180). @default 1.6 */ density?: number; /** Particle color; any CSS color. @default "var(--muted-foreground)" */ color?: string; /** Draw faint lines between nearby particles. @default true */ links?: boolean; /** Distance in px under which particles are linked. @default 110 */ linkDistance?: number; /** Radius in px of the cursor's push. @default 120 */ repelRadius?: number; /** Strength of the cursor's push. @default 1 */ repelStrength?: number; /** Drift speed in px per frame. @default 0.25 */ speed?: number; /** Seed for where particles start and how they drift; the same seed and size give the same field. @default "particles" */ seed?: string | number; } type RGBA = [number, number, number, number]; 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 ParticleField({ density = 1.6, color = "var(--muted-foreground)", links = true, linkDistance = 110, repelRadius = 120, repelStrength = 1, speed = 0.25, seed = "particles", className, children, ...props }: ParticleFieldProps) { 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 n = 0; // x, y, vx, vy, drift-x, drift-y, radius for each particle. let p = new Float32Array(0); let rgb: RGBA = [0, 0, 0, 1]; const pointer = { x: 0, y: 0, inside: false }; let frame = 0; let visible = true; const scatter = () => { const random = createRandom(seed); n = Math.min(180, Math.round(((width * height) / 10_000) * density)); p = new Float32Array(n * 7); for (let i = 0; i < n; i++) { const angle = random.next() * Math.PI * 2; const s = speed * random.range(0.4, 1); p.set([random.next() * width, random.next() * height, Math.cos(angle) * s, Math.sin(angle) * s, Math.cos(angle) * s, Math.sin(angle) * s, random.range(1, 2.3)], i * 7); } }; const draw = () => { ctx.clearRect(0, 0, width, height); const [r, g, b, a] = rgb; if (links) { ctx.strokeStyle = `rgb(${r},${g},${b})`; ctx.lineWidth = 1; const max2 = linkDistance * linkDistance; for (let i = 0; i < n; i++) { for (let j = i + 1; j < n; j++) { const dx = p[i * 7] - p[j * 7]; const dy = p[i * 7 + 1] - p[j * 7 + 1]; const d2 = dx * dx + dy * dy; if (d2 > max2) continue; ctx.globalAlpha = a * 0.35 * (1 - Math.sqrt(d2) / linkDistance); ctx.beginPath(); ctx.moveTo(p[i * 7], p[i * 7 + 1]); ctx.lineTo(p[j * 7], p[j * 7 + 1]); ctx.stroke(); } } } ctx.globalAlpha = a * 0.85; ctx.fillStyle = `rgb(${r},${g},${b})`; ctx.beginPath(); for (let i = 0; i < n; i++) { ctx.moveTo(p[i * 7] + p[i * 7 + 6], p[i * 7 + 1]); ctx.arc(p[i * 7], p[i * 7 + 1], p[i * 7 + 6], 0, Math.PI * 2); } ctx.fill(); ctx.globalAlpha = 1; }; const step = () => { for (let i = 0; i < n; i++) { const k = i * 7; // Velocity relaxes back toward the particle's own gentle drift after a push. p[k + 2] += (p[k + 4] - p[k + 2]) * 0.03; p[k + 3] += (p[k + 5] - p[k + 3]) * 0.03; if (pointer.inside) { const dx = p[k] - pointer.x; const dy = p[k + 1] - pointer.y; const d = Math.hypot(dx, dy); if (d < repelRadius && d > 0.01) { const force = (1 - d / repelRadius) * repelStrength * 0.9; p[k + 2] += (dx / d) * force; p[k + 3] += (dy / d) * force; } } p[k] += p[k + 2]; p[k + 1] += p[k + 3]; // Wrap around the edges. if (p[k] < -10) p[k] = width + 10; else if (p[k] > width + 10) p[k] = -10; if (p[k + 1] < -10) p[k + 1] = height + 10; else if (p[k + 1] > height + 10) p[k + 1] = -10; } }; const tick = () => { step(); draw(); frame = requestAnimationFrame(tick); }; const run = () => { cancelAnimationFrame(frame); if (!reduce && visible) 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); rgb = resolveColor(root, color); scatter(); 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"; }; const onLeave = () => (pointer.inside = false); layout(); run(); const resizeObserver = new ResizeObserver(layout); resizeObserver.observe(root); // Pause the simulation while the field is offscreen. const intersection = new IntersectionObserver(([entry]) => { visible = entry.isIntersecting; run(); }); intersection.observe(root); // A theme switch changes what the color resolves to. const unobserveTheme = observeTheme(() => { rgb = resolveColor(root, color); draw(); }); root.addEventListener("pointermove", onMove); root.addEventListener("pointerleave", onLeave); return () => { cancelAnimationFrame(frame); resizeObserver.disconnect(); intersection.disconnect(); unobserveTheme(); root.removeEventListener("pointermove", onMove); root.removeEventListener("pointerleave", onLeave); }; }, [density, color, links, linkDistance, repelRadius, repelStrength, speed, seed]); 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 { ParticleField } from "@/components/ui/particle-field/particle-field";
export function Hero() {
return (
<ParticleField className="h-96">
<h1>Everything is connected</h1>
</ParticleField>
);
}API reference
| Prop | Type | Default | Description |
|---|---|---|---|
density | number | 1.6 | Particles per 10,000 px² (capped at 180). |
color | string | "var(--muted-foreground)" | Particle color; any CSS color. |
links | boolean | true | Draw faint lines between nearby particles. |
linkDistance | number | 110 | Distance in px under which particles are linked. |
repelRadius | number | 120 | Radius in px of the cursor's push. |
repelStrength | number | 1 | Strength of the cursor's push. |
speed | number | 0.25 | Drift speed in px per frame. |
seed | string | number | "particles" | Seed for where particles start and how they drift; the same seed and size give the same field. |
Also accepts every native <div> attribute, including ref and className.
- Particles drift slowly, get pushed away by the cursor, and ease back to their own drift. Nearby particles are linked by faint lines.
- The simulation pauses while offscreen. Density is per area (capped at 180 particles), so larger fields get more particles.
- Colors accept any CSS color, including theme variables.
- Starting positions and drift come from `seed`, so a field of the same size starts the same way on every load (and the reduced-motion still frame matches). Pass a different seed to vary it.
Accessibility
- The canvas is decorative and aria-hidden.
- With reduced motion enabled, particles are drawn once and hold still.