Mikkel Manniche
Dansk English Deutsch

KI-Agenten

Voice orb

Eine runde Kugel für einen Sprachassistenten, die sich beim Nachdenken schneller dreht und mit der Lautstärke anschwillt.

Vorschau

Verwendung

voice-orb-demo.tsx
import { useEffect, useState } from 'react'
import { VoiceOrb, type VoiceOrbState } from '@/components/voice-orb'

const STATES: VoiceOrbState[] = ['idle', 'listening', 'thinking', 'speaking']

export default function VoiceOrbDemo() {
  const [state, setState] = useState<VoiceOrbState>('listening')
  const [level, setLevel] = useState(0)

  // Stand-in for a microphone: a wobbling loudness while someone is talking.
  useEffect(() => {
    if (state !== 'listening' && state !== 'speaking') return
    let raf = 0
    const tick = (now: number) => {
      const s = now / 1000
      setLevel(Math.max(0, Math.sin(s * 7) * 0.5 + Math.sin(s * 2.3) * 0.4 + Math.sin(s * 13) * 0.2))
      raf = requestAnimationFrame(tick)
    }
    raf = requestAnimationFrame(tick)
    return () => cancelAnimationFrame(raf)
  }, [state])

  return (
    <div className="flex flex-col items-center gap-6 py-4">
      <VoiceOrb state={state} level={level} />
      <div role="group" aria-label="State" className="flex flex-wrap justify-center gap-1 rounded-full bg-muted p-1">
        {STATES.map((s) => (
          <button
            key={s}
            type="button"
            aria-pressed={state === s}
            onClick={() => setState(s)}
            className="min-h-10 rounded-full px-3.5 text-sm text-muted-foreground capitalize transition-colors duration-150 hover:text-foreground aria-pressed:bg-background aria-pressed:text-foreground aria-pressed:shadow-sm"
          >
            {s}
          </button>
        ))}
      </div>
    </div>
  )
}

Quellcode

voice-orb.tsx
import { MotionConfig, motion, useSpring } from 'motion/react'
import { useEffect } from 'react'
import { cn } from '@/lib/utils'

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

export type VoiceOrbProps = {
  state: VoiceOrbState
  /** 0 to 1, the loudness right now (from the microphone or the voice being played). */
  level?: number
  /** Diameter in px. */
  size?: number
  labels?: Partial<Record<VoiceOrbState, string>>
  className?: string
}

const EN: Record<VoiceOrbState, string> = { idle: 'Voice assistant ready', listening: 'Listening', thinking: 'Thinking', speaking: 'Speaking' }

// How fast the colour layers turn in each state, in seconds per round.
const SPIN: Record<VoiceOrbState, [number, number]> = { idle: [18, 26], listening: [9, 13], thinking: [2.6, 3.8], speaking: [6, 8] }

/**
 * A round orb for a voice assistant. Colour layers turn slowly when idle and fast while thinking,
 * and the orb swells with the loudness of whoever is talking.
 */
export function VoiceOrb({ state, level = 0, size = 160, labels = {}, className }: VoiceOrbProps) {
  const t = { ...EN, ...labels }
  const live = state === 'listening' || state === 'speaking'
  const swell = useSpring(1, { stiffness: 300, damping: 30 })
  const glow = useSpring(0.35, { stiffness: 200, damping: 30 })

  useEffect(() => {
    const l = live ? Math.min(1, Math.max(0, level)) : 0
    swell.set(1 + l * 0.14)
    glow.set(0.35 + l * 0.6)
  }, [level, live, swell, glow])

  const [a, b] = SPIN[state]

  return (
    <MotionConfig reducedMotion="user">
      <div role="status" aria-label={t[state]} className={cn('relative inline-grid place-items-center', className)} style={{ width: size, height: size }}>
        {/* Soft light around the orb, stronger when it's loud. */}
        <motion.span aria-hidden className="absolute inset-0 rounded-full bg-[radial-gradient(closest-side,var(--color-primary),transparent)] blur-2xl" style={{ opacity: glow, scale: swell }} />

        <motion.span
          aria-hidden
          className="relative size-full overflow-hidden rounded-full bg-[oklch(0.25_0.06_265)] shadow-[inset_0_-8px_24px_rgb(0_0_0/0.35)]"
          style={{ scale: swell }}
          animate={state === 'thinking' ? { opacity: [1, 0.8, 1] } : { opacity: 1 }}
          transition={state === 'thinking' ? { duration: 1.4, repeat: Infinity, ease: 'easeInOut' } : { duration: 0.3 }}
        >
          {/* Two colour layers turning opposite ways; blurred so they melt into each other. */}
          <span
            className="absolute -inset-1/4 animate-spin bg-[conic-gradient(from_0deg,oklch(0.72_0.17_250),oklch(0.78_0.14_200),transparent_45%,oklch(0.7_0.2_300),transparent_80%)] blur-xl motion-reduce:animate-none"
            style={{ animationDuration: `${a}s` }}
          />
          <span
            className="absolute -inset-1/4 animate-spin bg-[conic-gradient(from_120deg,transparent,oklch(0.85_0.12_180),transparent_35%,oklch(0.65_0.22_280),transparent_70%)] opacity-80 mix-blend-screen blur-lg motion-reduce:animate-none"
            style={{ animationDuration: `${b}s`, animationDirection: 'reverse' }}
          />
          {/* A glassy highlight on top. */}
          <span className="absolute inset-0 rounded-full bg-[radial-gradient(circle_at_32%_26%,rgb(255_255_255/0.55),transparent_42%)]" />
        </motion.span>
      </div>
    </MotionConfig>
  )
}

Installieren

npx shadcn@latest add https://mikkelmanniche.dk/lab/r/voice-orb.json

Mit eingetragener Registry (components.json)

npx shadcn@latest add @manniche/voice-orb

Abhängigkeiten

Dateien

  • voice-orb.tsx