MynduiMyndui
0

Anatomy of the Voice Orb

How a Siri-style orb is three absolute layers, an amp-shaped curve, and a wrapper/child split so scale never fights spin.

01/04Three layers, one box
Corona
conic spin · amp scale
Core
breathe + amp swell
Ring
listening only
tsx
<div data-state={state} style={{ "--amp": level } as CSSProperties}><div style={{ transform: `scale(${0.92 + level * 0.35})` }}>  <div className="… animate-voice-orb-spin …" />   {/* corona */}</div><div className="… animate-voice-orb-breathe …">  <div style={{ transform: `scale(${1 + level * 0.22})` }} />  {/* core */}</div><div data-on={state === "listening"} className="… animate-voice-orb-ring" /></div>
01

Three layers, one box

An orb that "hears" you isn't a shader — it's a corona, a core, and a ring, all reading the same --amp CSS variable. Here's every piece.

VoiceOrb is a square grid with three absolutely positioned children. The corona is a blurred conic gradient that spins forever. The core is a radial sphere with a specular highlight. The listening ring is a border that only exists while state="listening".

Everything shares the same box. Only which layer is visible — and how hard --amp pushes it — changes with state.

02/04Why scale and spin live on separate nodes
Wrapper
inline scale(--amp)
Inner
animate-voice-orb-spin
tsx
{/* Corona — amplitude swells the wrapper; the inner layer spins. */}<div style={{ transform: `scale(${0.92 + level * 0.35})` }}><div className="animate-voice-orb-spin …" /></div>
02

Why scale and spin live on separate nodes

A CSS keyframe that sets transform: rotate(…) will overwrite any inline transform: scale(…). The fix is structural: put amplitude scale on a wrapper, put the spin keyframe on the child. They compose instead of clobbering each other.

The core uses the same pattern: animate-voice-orb-breathe on the outer shell, inline amplitude scale on the painted sphere inside.

Raw analyser levels jitter. Before they touch CSS, they're clamped and eased through c ** 0.6 — quiet speech gets visible life without loud peaks blowing the orb out:

tsx
 
const shape = (a: number) => {
  const c = Math.min(1, Math.max(0, a));
  return c ** 0.6;
};
const level = state === "idle" ? 0 : shape(amplitude);

Idle hard-zeros the level so the breathe keyframe is the only motion. The listening ring also shortens its duration as level rises (1.6 - level * 0.7s), so louder input pulses faster.

03/04The three states
Idle
amp → 0 · breathe only
Listening
ring duration ∝ amp
Speaking
core + corona swell
03

The three states

StateWhat moves
idleCore breathe only; --amp forced to 0
listeningRing expands/fades; duration tracks amp
speakingCorona + core swell with amp

useAudioAmplitude turns a MediaStream into that 01 level via RMS of the time-domain waveform, cleaned up with cancelAnimationFrame + ctx.close() when the stream drops.

04/04Result
04

The result

Toggle states and watch the same three layers reconfigure around a live --amp.

Three layers, one shaped number, and a wrapper/child split so transforms compose — that's the whole orb.

Accessibility

Every infinite keyframe carries motion-reduce:animate-none. Under reduced motion the orb holds its resolved silhouette — still readable as a lit sphere, just not breathing or spinning.

tsx
 
"animate-voice-orb-spin motion-reduce:animate-none"
"animate-voice-orb-breathe motion-reduce:animate-none"
"data-[on=true]:animate-voice-orb-ring motion-reduce:animate-none"

Motion Score

Voice OrbSSCompositor-only
SscaleScale spring / press
SopacityFade / cross-fade
Each property is graded by how the browser runs it, from S (composited off the main thread) down to F (layout thrashing); the component takes the worst. MotionScore methodology →