← Library

Code Pack

AI Status Orb

WebGLVanilla JSReactTypeScript

A particle orb that shows what your AI is doing. 16,000 particles morph between four states: a dust sphere that listens, a knot that reasons, a sonar that searches and a check mark that draws itself in when it’s done. One WebGL draw call, no dependencies, with a React wrapper and a mic-level hook for voice apps.

Get the code ↓
Build an "AI Status Orb": a single-page demo with a WebGL particle orb that morphs between four AI states — Listening, Reasoning, Searching, Done — controlled by a segmented control. No framework, no dependencies, one self-contained index.html. The engine lives in its own ES module (ai-status-orb.js) so it can be dropped into any app.

## Color palette
--bg: #0c0d0d (page background, near-black graphite)
--bg-lift: #191b1b (center of the radial background gradient)
--ink: #f2f2f0 (primary text)
--mute: #8b8e8c (secondary text)
--faint: #5b5e5c (inactive tabs, hints)
--edge: rgba(255,255,255,.12) (hairline borders)
State colors (one per state, used for particles, tab dots and the halo):
Listening #ff8fa3 (rose) · Reasoning #7c9cff (periwinkle) · Searching #ffbf5e (amber) · Done #6ee7b0 (mint)

## Typography
Inter via Google Fonts, weights 400 / 500 / 600.
H1: weight 500, letter-spacing -0.035em, line-height 1.04, sentence case. Desktop clamp(36px, 3.7vw, 54px), mobile clamp(32px, 8.6vw, 64px).
Sub: 15–18px, color --mute, line-height 1.45, max 44ch.
Tabs: 14px weight 500, letter-spacing -0.01em.
No uppercase labels, no monospace, no accent-colored words in the headline.

## Layout
Page background: radial-gradient(90% 70% at 50% 55%, --bg-lift 0%, --bg 65%).
Top bar: name on the left (14px, --mute), pill link on the right ("More builds in the library", 13px, 1px --edge border, radius 999px, padding 7px 14px, background rgba(255,255,255,.03)).
Desktop (≥900px and landscape): 2-column grid 5fr / 7fr, padding 36px 56px 44px. Left column vertically centered: H1, sub, then 40px gap, then the segmented control and a hint line ("Click a state, or press 1–4", with <kbd> keys). Right column: the orb canvas filling the cell.
Mobile / portrait: single column in this order: bar, copy, orb (takes the remaining height, min 280px), controls at the bottom.
Behind the canvas: a circular halo div (min(90%, 720px), aspect-ratio 1) with radial-gradient(circle, rgba(accent,.09) 0%, rgba(accent,.03) 35%, transparent 65%). The accent is a CSS variable --accent (r,g,b) updated every frame from the orb's current blended color.

## Segmented control
Grid of 4 equal buttons inside a container: padding 4px, radius 16px, background rgba(255,255,255,.035), border 1px rgba(255,255,255,.06), max-width 560px.
A sliding knob sits behind the active button: absolute, top/bottom 4px, radius 12px, background rgba(255,255,255,.09), inset 1px rgba(255,255,255,.07) shadow. Move it with transform: translateX(button.offsetLeft) and width = button.offsetWidth, transition .38s cubic-bezier(.2,.8,.2,1).
Each button: a 6px dot in the state color (opacity 0 → 1 when active) + label. Inactive text --faint, hover --mute, active --ink. Use aria-pressed.
Keyboard: 1–4 select a state, ArrowLeft / ArrowRight cycle. Demo autoplay: advance every 3.2s until the user clicks or presses a key.

## Particle engine (ai-status-orb.js)
export createAIStatusOrb(canvas, options) and ORB_STATES = ["listening","reasoning","searching","done"].
Options: particles 16000, colors (4 hex), scale 0.8, pointSize 0.011, glow 0.018, speed 1, transition 1.15s, maxDpr 2, seed, autoplay, preserveDrawingBuffer, onStateChange.
API: setState(nameOrIndex, {instant}), getState(), getColor() → [r,g,b] blended, setVoiceLevel(0..1 | null), setColors(hexes), renderAt(seconds), play(), pause(), resize(), destroy().

WebGL1, context {alpha:true, premultipliedAlpha:true, antialias:false}. All particles are GL_POINTS in ONE buffer pair (positions vec3, colors vec3), updated with bufferData(DYNAMIC_DRAW) every frame. Additive blending: blendFunc(ONE, ONE), clearColor(0,0,0,0).
Vertex shader: simple perspective f = 3.4, s = f / (f - z); screen position = center + (x, -y) * R * s; gl_PointSize = uPt * s; color *= (0.5 + 0.5*s) * uGain.
Fragment shader: round soft point a = smoothstep(0.5, 0.08, length(gl_PointCoord - 0.5)); output vec4(color*a, max(r,g,b)) so it composites on any dark background.
Two draw passes per frame: a glow pass (point size ×5, gain = glow) then the sharp pass (gain 1). Orb radius R = min(canvasW, canvasH)/2 * scale, centered in the canvas.
Seed everything with a deterministic LCG (seed*1664525 + 1013904223) so renders are repeatable: 4 random floats per particle (s1..s4), a Fibonacci-sphere direction per particle, and two shuffled index permutations.

## The four states (unit space, radius ≈ 1)
Listening — dust sphere that swells with a voice: take the particle's Fibonacci-sphere direction, ripple = sin(lat*6 - t*3.2)*(0.03 + 0.07*voice) + sin(lon*4 + t*1.2)*0.015, radius = (0.8 + ripple) * s3^0.12. Rotate Y by t*0.22, X by 0.3. Brightness 0.28 + max(0, ripple)*4 + 0.18*s4. voice = 0.5 + 0.5*sin(t*1.9)*sin(t*3.1+1.2) unless setVoiceLevel() feeds a real mic level.
Reasoning — particles flowing along a trefoil knot: u = (s1 + t*0.04)*2π, r = cos(3u) + 2, point = 0.29*(r cos 2u, r sin 2u, -sin 3u) + a random offset inside a 0.09 tube. Rotate Y by t*0.34, X by 0.55 + sin(t*0.3)*0.35. Brightness 0.26 + 0.4*s4.
Searching — tilted sonar: 70% of particles sit on 5 concentric rings (radius (floor(s2*5)+1)/5 * 0.92), 30% fill the disc. A beam rotates at 2.2 rad/s; glow = exp(-1.6 * angular distance behind the beam), lifting particles slightly (y += glow*0.06). Rotate Y by t*0.14, X by -0.8 so the disc reads in perspective. Brightness 0.2 (rings) / 0.08 (fill) + 1.25*glow.
Done — a ring (50% of particles, radius 0.86) and a check mark polyline (-0.38,0.02) → (-0.11,-0.26) → (0.42,0.30), particles spread ±0.035 across the stroke. Gentle breathing scale 1 + 0.015*sin(t*1.6), slight Y sway sin(t*0.6)*0.2.
A few "spark" particles (s4 > 0.965) get +0.25 white. Final particle color = stateColor * brightness * 0.8.

## Transitions
On setState: snapshot current positions and colors, store T0 = now. For each particle: progress l = clamp((t - T0 - delay) / transition), e = easeInOutCubic(l). Position = lerp(from, target, e) + randomDirection * sin(π*l) * 0.24 * (0.4 + s2) — the orb bursts outward mid-morph then settles. Colors lerp the same way.
Delay: s1*0.22 for most states. For Done, the ring arrives first (s3*0.35) and the check draws in along its length (0.3 + s2*0.4).
getColor() blends the previous and current state colors with easeInOutCubic((t - T0)/transition).

## Performance & accessibility
Cap DPR at 2, resize the canvas from clientWidth/Height × dpr on every frame when it changed. Pause the loop when the canvas is off screen (IntersectionObserver) or the tab is hidden. prefers-reduced-motion: speed × 0.35, transition × 0.6, no CSS transitions. If WebGL is missing, show a short text message instead of the canvas. Canvas has role="img" and an aria-label.
Expose window.orb and a screenshot helper window.shot(state, t, fromState?, dt?) when the URL has ?still (autoplay off, render one exact frame).

## Files
index.html — demo page, engine inlined so it opens by double-click
ai-status-orb.js — the engine as an ES module
ai-status-orb.d.ts — TypeScript types
AIStatusOrb.tsx — "use client" React wrapper: create on mount, setState on prop change, setVoiceLevel prop, destroy on unmount

## Notes
Target 60fps on a mid-range phone with 16,000 particles; drop to 8,000 via the particles option for low-end devices. Everything is computed on the CPU in one loop — no textures, no post-processing, no libraries.