Skip to content

Voice Orb ​

A living orb for voice assistants that deforms with the audio level as it listens, thinks and speaks.

Voice assistant idle

Tap to talk

Installation ​

pnpm dlx shadcn-vue@latest add https://vuelix.dev/r/voice-orb.json
Manual installation

Install the dependencies:

bash
npm install motion-v

Then copy these files into components/ui/voice-orb/ (plus lib/utils.ts if you don't have it):

vue
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { motion, useMotionValue, useReducedMotion, useSpring, useTransform } from 'motion-v'
import { computed, onBeforeUnmount, onMounted, ref, useId, watch } from 'vue'
import { cn } from '@/lib/utils'

export type VoiceOrbState = 'idle' | 'listening' | 'thinking' | 'speaking'

const props = withDefaults(defineProps<{
  /** What the assistant is doing — changes colour, speed and how much the orb reacts to `level`. */
  state?: VoiceOrbState
  /** Live audio level, 0–1. Feed it from an AnalyserNode (mic while listening, TTS while speaking). */
  level?: number
  /** Diameter in px. */
  size?: number
  /** Screen-reader text announced when the state changes. Defaults to a label per state. */
  label?: string
  class?: HTMLAttributes['class']
}>(), {
  state: 'idle',
  level: 0,
  size: 176,
})

interface Preset {
  /** Base deformation of the blobs. */
  amp: number
  /** How strongly `level` adds deformation and scale. */
  react: number
  /** Time multiplier for the wobble. */
  speed: number
  /** Overall scale. */
  scale: number
  /** Idle breathing strength. */
  breath: number
  /** Baseline halo energy. */
  glow: number
}

// Tune the personality of each state here.
const PRESETS: Record<VoiceOrbState, Preset> = {
  idle: { amp: 0.05, react: 0, speed: 0.45, scale: 0.96, breath: 1, glow: 0.15 },
  listening: { amp: 0.06, react: 0.3, speed: 0.9, scale: 1, breath: 0.4, glow: 0.35 },
  thinking: { amp: 0.09, react: 0, speed: 2.4, scale: 0.9, breath: 0, glow: 0.3 },
  speaking: { amp: 0.07, react: 0.38, speed: 1.5, scale: 1.02, breath: 0.2, glow: 0.45 },
}

// Gradient stops use `stop-color: currentColor`, so plain text-* token classes colour them.
// [back blob from → to, front blob from → to, core]
const PALETTES: Record<VoiceOrbState, [string, string, string, string, string]> = {
  idle: ['text-chart-2', 'text-chart-3', 'text-chart-1', 'text-chart-2', 'text-chart-1'],
  listening: ['text-chart-2', 'text-chart-1', 'text-chart-2', 'text-chart-3', 'text-chart-2'],
  thinking: ['text-chart-3', 'text-chart-5', 'text-chart-3', 'text-chart-2', 'text-chart-3'],
  speaking: ['text-chart-1', 'text-chart-4', 'text-chart-1', 'text-chart-2', 'text-chart-1'],
}
const HALO: Record<VoiceOrbState, string> = {
  idle: 'bg-chart-2/50',
  listening: 'bg-chart-2/70',
  thinking: 'bg-chart-3/70',
  speaking: 'bg-chart-1/70',
}
const LABELS: Record<VoiceOrbState, string> = {
  idle: 'Voice assistant idle',
  listening: 'Listening',
  thinking: 'Thinking',
  speaking: 'Speaking',
}

const LEVEL_SPRING = { stiffness: 260, damping: 24, mass: 0.5 }

const uid = useId()
const ids = { back: `${uid}-back`, front: `${uid}-front`, core: `${uid}-core`, blur: `${uid}-blur` }
const reduced = useReducedMotion()
const palette = computed(() => PALETTES[props.state])

const clamp01 = (v: number) => Math.min(1, Math.max(0, Number.isFinite(v) ? v : 0))

// Audio level → spring so jittery analyser data turns into smooth, organic motion.
const rawLevel = useMotionValue(clamp01(props.level))
const level = useSpring(rawLevel, LEVEL_SPRING)
watch(() => props.level, v => rawLevel.set(clamp01(v)))

// 0–1 "energy" drives the halo; written every frame (or on change with reduced motion).
const energy = useMotionValue(PRESETS[props.state].glow)
const haloScale = useTransform(energy, [0, 1], [0.82, 1.28])
const haloOpacity = useTransform(energy, [0, 1], [0.35, 0.95])

/** Smooth closed blob through `n` wobbling points (Catmull-Rom → cubic Bézier). */
function blob(r: number, amp: number, t: number, seed: number, n = 12) {
  const pts: [number, number][] = []
  for (let i = 0; i < n; i++) {
    const a = (i / n) * Math.PI * 2
    const wobble
      = Math.sin(a * 2 + t * 1.3 + seed) * 0.5
        + Math.sin(a * 3 - t * 0.9 + seed * 2) * 0.32
        + Math.sin(a * 5 + t * 2.1 + seed * 3) * 0.18
    const rr = r * (1 + amp * wobble)
    pts.push([100 + Math.cos(a) * rr, 100 + Math.sin(a) * rr])
  }
  const p = (i: number) => pts[(i + n) % n]!
  let d = `M${p(0)[0].toFixed(2)} ${p(0)[1].toFixed(2)}`
  for (let i = 0; i < n; i++) {
    const [p0, p1, p2, p3] = [p(i - 1), p(i), p(i + 1), p(i + 2)]
    const c1x = p1[0] + (p2[0] - p0[0]) / 6
    const c1y = p1[1] + (p2[1] - p0[1]) / 6
    const c2x = p2[0] - (p3[0] - p1[0]) / 6
    const c2y = p2[1] - (p3[1] - p1[1]) / 6
    d += `C${c1x.toFixed(2)} ${c1y.toFixed(2)} ${c2x.toFixed(2)} ${c2y.toFixed(2)} ${p2[0].toFixed(2)} ${p2[1].toFixed(2)}`
  }
  return `${d}Z`
}

// Static shapes for SSR and the first paint; the frame loop takes over after mount.
const initial = { back: blob(66, 0.03, 0, 0), front: blob(58, 0.03, 0, 2.1), core: blob(32, 0.03, 0, 4.2) }

const backEl = ref<SVGPathElement>()
const frontEl = ref<SVGPathElement>()
const coreEl = ref<SVGPathElement>()
const groupEl = ref<SVGGElement>()
const arcEl = ref<SVGCircleElement>()
const backGrad = ref<SVGLinearGradientElement>()
const frontGrad = ref<SVGLinearGradientElement>()

const cur: Preset = { ...PRESETS[props.state] }
let t = 0
let last = 0
let raf = 0

function draw(now: number, lv: number) {
  const amp = cur.amp + cur.react * lv
  backEl.value?.setAttribute('d', blob(66, amp * 1.15, t, 0))
  frontEl.value?.setAttribute('d', blob(58, amp, t * 1.25, 2.1))
  coreEl.value?.setAttribute('d', blob(32, amp * 1.5, t * 1.6, 4.2))
  const breathe = 1 + 0.03 * Math.sin((now / 1000) * 1.4) * cur.breath
  const s = cur.scale * breathe + lv * cur.react * 0.45
  groupEl.value?.setAttribute('transform', `translate(100 100) scale(${s.toFixed(4)}) translate(-100 -100)`)
  backGrad.value?.setAttribute('gradientTransform', `rotate(${(t * 38) % 360} .5 .5)`)
  frontGrad.value?.setAttribute('gradientTransform', `rotate(${(-t * 52) % 360} .5 .5)`)
  arcEl.value?.setAttribute('transform', `rotate(${((now / 1000) * 240) % 360} 100 100)`)
  energy.set(Math.min(1, cur.glow + lv * cur.react * 2.4))
}

function tick(now: number) {
  const dt = last ? Math.min(0.05, (now - last) / 1000) : 0
  last = now
  // Ease every parameter toward the current state so state changes morph instead of snapping.
  const target = PRESETS[props.state]
  const k = 1 - Math.exp(-dt * 3.5)
  for (const key of Object.keys(target) as (keyof Preset)[])
    cur[key] += (target[key] - cur[key]) * k
  t += dt * cur.speed
  draw(now, level.get())
  raf = requestAnimationFrame(tick)
}

function start() {
  cancelAnimationFrame(raf)
  last = 0
  raf = requestAnimationFrame(tick)
}

/** Reduced motion: no loop — a calm, near-circular orb that only updates when inputs change. */
function drawStill() {
  cancelAnimationFrame(raf)
  Object.assign(cur, PRESETS[props.state], { amp: 0, breath: 0, react: 0 })
  draw(0, 0)
  energy.set(Math.min(1, PRESETS[props.state].glow + clamp01(props.level) * PRESETS[props.state].react * 2))
}

onMounted(() => (reduced.value ? drawStill() : start()))
watch(reduced, r => (r ? drawStill() : start()))
watch(() => [props.state, props.level], () => reduced.value && drawStill())
onBeforeUnmount(() => cancelAnimationFrame(raf))
</script>

<template>
  <div
    :class="cn('relative inline-grid shrink-0 place-items-center', props.class)"
    :style="{ width: `${props.size}px`, height: `${props.size}px` }"
    :data-state="props.state"
  >
    <motion.div
      aria-hidden="true"
      :class="cn('pointer-events-none absolute inset-[8%] rounded-full blur-2xl transition-colors duration-700', HALO[props.state])"
      :style="{ scale: haloScale, opacity: haloOpacity }"
    />
    <template v-if="props.state === 'listening' && !reduced">
      <motion.div
        v-for="i in 2"
        :key="i"
        aria-hidden="true"
        class="pointer-events-none absolute inset-[16%] rounded-full border-2 border-chart-2/60"
        :initial="{ scale: 1, opacity: 0 }"
        :animate="{ scale: [1, 1.5], opacity: [0.6, 0] }"
        :transition="{ duration: 2.2, repeat: Infinity, ease: 'easeOut', delay: (i - 1) * 1.1 }"
      />
    </template>

    <svg viewBox="0 0 200 200" class="relative size-full overflow-visible" aria-hidden="true">
      <defs>
        <linearGradient :id="ids.back" ref="backGrad" x1="0" y1="0" x2="1" y2="1">
          <stop offset="0" stop-color="currentColor" :class="cn('transition-colors duration-700', palette[0])" />
          <stop offset="1" stop-color="currentColor" :class="cn('transition-colors duration-700', palette[1])" />
        </linearGradient>
        <linearGradient :id="ids.front" ref="frontGrad" x1="0" y1="1" x2="1" y2="0">
          <stop offset="0" stop-color="currentColor" :class="cn('transition-colors duration-700', palette[2])" />
          <stop offset="1" stop-color="currentColor" :class="cn('transition-colors duration-700', palette[3])" />
        </linearGradient>
        <radialGradient :id="ids.core" cx="0.4" cy="0.35" r="0.75">
          <stop offset="0" stop-color="currentColor" stop-opacity="0.95" :class="cn('transition-colors duration-700', palette[4])" />
          <stop offset="1" stop-color="currentColor" stop-opacity="0" :class="cn('transition-colors duration-700', palette[4])" />
        </radialGradient>
        <filter :id="ids.blur" x="-20%" y="-20%" width="140%" height="140%">
          <feGaussianBlur stdDeviation="5" />
        </filter>
      </defs>

      <!-- Thinking: an orbiting arc -->
      <circle
        ref="arcEl"
        cx="100"
        cy="100"
        r="90"
        fill="none"
        stroke-width="2.5"
        stroke-linecap="round"
        stroke-dasharray="70 495"
        :class="cn('stroke-chart-3 transition-opacity duration-500', props.state === 'thinking' ? 'opacity-90' : 'opacity-0')"
      />

      <g ref="groupEl" transform="translate(100 100) scale(0.96) translate(-100 -100)">
        <path ref="backEl" :d="initial.back" :fill="`url(#${ids.back})`" :filter="`url(#${ids.blur})`" opacity="0.9" />
        <path ref="frontEl" :d="initial.front" :fill="`url(#${ids.front})`" opacity="0.88" />
        <path ref="coreEl" :d="initial.core" :fill="`url(#${ids.core})`" />
      </g>
    </svg>

    <span class="sr-only" role="status">{{ props.label ?? LABELS[props.state] }}</span>
  </div>
</template>
ts
export { default as VoiceOrb } from './VoiceOrb.vue'
export type { VoiceOrbState } from './VoiceOrb.vue'

Usage ​

ts
import { VoiceOrb } from '@/components/ui/voice-orb'
vue
<VoiceOrb :state="state" :level="level" />

Feed level from a Web Audio AnalyserNode — the microphone while listening, your TTS output while speaking:

ts
const analyser = audioContext.createAnalyser()
const data = new Uint8Array(analyser.frequencyBinCount)

function sample() {
  analyser.getByteTimeDomainData(data)
  let peak = 0
  for (const v of data) peak = Math.max(peak, Math.abs(v - 128) / 128)
  level.value = Math.min(1, peak * 1.6)
  requestAnimationFrame(sample)
}

The level runs through a spring, so noisy analyser data still moves smoothly. Each state eases into the next instead of snapping. With prefers-reduced-motion the orb stops its idle loop and only redraws when state or level change.

API ​

PropTypeDefaultDescription
state'idle' | 'listening' | 'thinking' | 'speaking''idle'Sets the colours, speed and how strongly the orb reacts to level. Thinking adds an orbiting arc, listening adds ripples.
levelnumber0Audio level from 0 to 1. Only deforms the orb while listening or speaking.
sizenumber176Diameter in px.
labelstringper stateText announced to screen readers when the state changes.

Designed by Kuldeep Pisda · Released under the MIT License