A drifting cluster of soft and sharp particles that pulse and wander — an AI "thinking / processing" state on canvas.
'use client'
import React, { useEffect, useRef } from 'react'
export type ThinkingParticlesProps = {
/** Canvas width in px */
width?: number
/** Canvas height in px */
height?: number
/** Number of particles */
count?: number
/** Base particle radius in px (each particle randomises around this) */
size?: number
/** How much particle sizes vary (0 = uniform, 1 = wide spread) */
sizeVariance?: number
/** How wide the cluster spreads (0..1 of the safe area) */
spread?: number
/** Orbit / motion speed multiplier */
speed?: number
/** How far particles drift/wobble from their orbit (px) */
drift?: number
/** Glow radius as a multiple of particle size (0 = no glow, hard dots) */
glow?: number
/** Glow opacity multiplier (0..1) */
glowStrength?: number
/** Fraction of particles that are soft/blurry vs sharp (0..1) */
blurAmount?: number
/** Overall particle brightness/opacity (0..1) */
brightness?: number
/** Particle color */
color?: string
className?: string
}
// mulberry32 — tiny deterministic PRNG so the layout is stable per-mount
function makeRng(seed: number) {
let s = seed >>> 0
return () => {
s |= 0
s = (s + 0x6d2b79f5) | 0
let t = Math.imul(s ^ (s >>> 15), 1 | s)
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
}
}
/**
* A cluster of white particles — a mix of sharp points and soft glowing
* blobs — that drift and pulse to evoke an AI "thinking / processing" state.
*/
export default function ThinkingParticles({
width = 220,
height = 120,
count = 16,
size = 3,
sizeVariance = 0.7,
spread = 0.8,
speed = 1,
drift = 6,
glow = 2.4,
glowStrength = 0.5,
blurAmount = 0.5,
brightness = 0.9,
color = '255,255,255',
className,
}: ThinkingParticlesProps) {
const canvasRef = useRef<HTMLCanvasElement>(null)
useEffect(() => {
const canvas = canvasRef.current
if (!canvas) return
const ctx = canvas.getContext('2d')
if (!ctx) return
const dpr = Math.min(window.devicePixelRatio || 1, 2)
canvas.width = width * dpr
canvas.height = height * dpr
ctx.scale(dpr, dpr)
const cx = width / 2
const cy = height / 2
const rng = makeRng(0x9e3779b9 ^ (count << 8) ^ Math.round(size * 100))
// largest footprint a particle can reach, so glows never hit the edge
const maxSize = size * (1 + sizeVariance) * 1.4 // *1.4 = pulse peak
const maxGlow = maxSize * Math.max(1, glow)
const safe = Math.max(4, Math.min(width, height) / 2 - maxGlow)
// radius of the region particles are allowed to roam within
const roam = spread * safe
type Particle = {
// current position, relative to centre
x: number
y: number
// home offset the particle is loosely tethered to
homeX: number
homeY: number
// independent flow-field frequencies/phases per axis
fx1: number
fx2: number
fy1: number
fy2: number
px1: number
px2: number
py1: number
py2: number
wanderSpeed: number
size: number
phase: number
pulse: number
/** 0 = razor sharp, 1 = fully soft/blurry */
soft: number
baseAlpha: number
}
const particles: Particle[] = Array.from({ length: count }, () => {
// scatter across the disc (sqrt for even area distribution)
const r = Math.sqrt(rng()) * roam
const a = rng() * Math.PI * 2
const hx = Math.cos(a) * r
const hy = Math.sin(a) * r
const s = size * (1 + (rng() * 2 - 1) * sizeVariance)
const soft = rng() < blurAmount ? 0.5 + rng() * 0.5 : rng() * 0.25
return {
x: hx,
y: hy,
homeX: hx,
homeY: hy,
// varied frequencies → non-repeating, organic wander
fx1: 0.3 + rng() * 0.9,
fx2: 0.5 + rng() * 1.3,
fy1: 0.3 + rng() * 0.9,
fy2: 0.5 + rng() * 1.3,
px1: rng() * Math.PI * 2,
px2: rng() * Math.PI * 2,
py1: rng() * Math.PI * 2,
py2: rng() * Math.PI * 2,
wanderSpeed: 0.6 + rng() * 0.9,
size: Math.max(0.8, s),
phase: rng() * Math.PI * 2,
pulse: 1 + rng() * 2.2,
soft,
baseAlpha: 0.4 + rng() * 0.6,
}
})
let raf = 0
let t = 0
const render = () => {
t += 0.016 * speed
ctx.clearRect(0, 0, width, height)
// source-over: overlaps layer naturally instead of blowing out to white
ctx.globalCompositeOperation = 'source-over'
for (const p of particles) {
const tw = t * p.wanderSpeed
// flow-field velocity: two layered sines per axis at differing
// frequencies → smooth, organic, non-repeating wandering
let vx =
Math.sin(tw * p.fx1 + p.px1) * 0.6 +
Math.sin(tw * p.fx2 + p.px2) * 0.4
let vy =
Math.cos(tw * p.fy1 + p.py1) * 0.6 +
Math.cos(tw * p.fy2 + p.py2) * 0.4
// soft spring pulling the particle toward its home offset, so
// it wanders freely but never drifts out of the cluster
vx += (p.homeX - p.x) * 0.02
vy += (p.homeY - p.y) * 0.02
p.x += vx * drift * 0.06 * speed
p.y += vy * drift * 0.06 * speed
// clamp inside the round roam disc as a safety net
const dist = Math.hypot(p.x, p.y)
if (dist > roam) {
const k = roam / dist
p.x *= k
p.y *= k
}
const x = cx + p.x
const y = cy + p.y
// fade particles out as they near the cluster edge, so the
// cluster dissolves softly instead of showing a hard boundary
const edge = 1 - Math.pow(dist / roam, 3)
const pulse = (Math.sin(t * p.pulse + p.phase) + 1) / 2
const alpha =
brightness * p.baseAlpha * (0.45 + pulse * 0.55) * edge
const s = p.size * (0.7 + pulse * 0.5)
// glow radius scales with how "soft" this particle is
const glowR = s * (1 + p.soft * (glow - 1))
if (glow > 0 && p.soft > 0.05) {
const gAlpha = alpha * glowStrength * p.soft
const grad = ctx.createRadialGradient(x, y, 0, x, y, glowR)
// soft particles: gentle falloff. sharp ones: tight core.
grad.addColorStop(0, `rgba(${color},${gAlpha})`)
grad.addColorStop(0.5, `rgba(${color},${gAlpha * 0.35})`)
grad.addColorStop(1, `rgba(${color},0)`)
ctx.fillStyle = grad
ctx.beginPath()
ctx.arc(x, y, glowR, 0, Math.PI * 2)
ctx.fill()
}
// solid core — bigger & brighter for sharp particles
const coreR = s * (0.35 + (1 - p.soft) * 0.55)
ctx.fillStyle = `rgba(${color},${Math.min(1, alpha + (1 - p.soft) * 0.2)})`
ctx.beginPath()
ctx.arc(x, y, coreR, 0, Math.PI * 2)
ctx.fill()
}
raf = requestAnimationFrame(render)
}
render()
return () => cancelAnimationFrame(raf)
}, [
width,
height,
count,
size,
sizeVariance,
spread,
speed,
drift,
glow,
glowStrength,
blurAmount,
brightness,
color,
])
return (
<canvas
ref={canvasRef}
className={className}
style={{ width, height }}
aria-hidden="true"
/>
)
}