Vault

Thinking Particles

A drifting cluster of soft and sharp particles that pulse and wander — an AI "thinking / processing" state on canvas.

Customize

Code

'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"
        />
    )
}