Darya

Voice Orb

Component

About

Glassy voice-assistant orb with four states: idle, listening, thinking, speaking. Rings flow in while listening and out while speaking, synced to the pulse. Feed level (0–1) from a mic or TTS. Solid or glass, no deps.

Built with React and Tailwind. Copy the prompt and paste it into Claude Code, Cursor or Lovable: your agent adds Voice Orb to your project and fits it to your theme.

Code

How Voice Orb is used: the demo's code. The component's own source comes with a free account.

Usage.tsx

"use client";

import * as React from "react";
import { VoiceOrb, type VoiceOrbState } from "@/components/ui/voice-orb";

// A scripted call: the orb listens, thinks and speaks while the transcript writes itself.
// In a real app, drive `state` from your voice pipeline and `level` from useAudioLevel().
const SCRIPT: [string, string][] = [
  ["Can you move my 3 pm with Anna to tomorrow?", "Sure. Tomorrow at 3 pm she's free, and so are you."],
  ["Great, and remind her to bring the contract.", "Done. I moved the meeting and sent Anna a note about the contract."],
  ["What else is on my calendar tomorrow?", "Just a 10 am design review. The rest of the day is open."],
];

type Msg = { id: number; who: "you" | "ai"; words: string[]; shown: number };

const words = (s: string) => s.split(" ");

export default function VoiceOrbInContext() {
  const [state, setState] = React.useState<VoiceOrbState>("idle");
  const [msgs, setMsgs] = React.useState<Msg[]>([]);
  const [turn, setTurn] = React.useState(0);
  const [running, setRunning] = React.useState(true);
  const [muted, setMuted] = React.useState(false);
  const [level, setLevel] = React.useState(0);
  const [seconds, setSeconds] = React.useState(0);
  const idRef = React.useRef(0);

  const last = msgs[msgs.length - 1];
  const push = (who: Msg["who"], text: string) =>
    setMsgs((m) => [...m, { id: ++idRef.current, who, words: words(text), shown: 0 }].slice(-4));
  const reveal = () => setMsgs((m) => m.map((x, i) => (i === m.length - 1 ? { ...x, shown: x.shown + 1 } : x)));

  // The conversation loop.
  React.useEffect(() => {
    if (!running) return;
    const [ask, answer] = SCRIPT[turn % SCRIPT.length];
    let t: ReturnType<typeof setTimeout>;
    if (state === "idle") {
      t = setTimeout(() => {
        push("you", ask);
        setState("listening");
      }, 1400);
    } else if (state === "listening") {
      if (muted) return; // a muted mic hears nothing, so the transcript waits
      t = last && last.shown < last.words.length ? setTimeout(reveal, 230) : setTimeout(() => setState("thinking"), 450);
    } else if (state === "thinking") {
      t = setTimeout(() => {
        push("ai", answer);
        setState("speaking");
      }, 1300);
    } else {
      t = last && last.shown < last.words.length
        ? setTimeout(reveal, 190)
        : setTimeout(() => {
            setState("idle");
            setTurn((n) => n + 1);
          }, 700);
    }
    return () => clearTimeout(t);
  }, [running, state, turn, muted, last?.shown]); // eslint-disable-line react-hooks/exhaustive-deps

  // Fake voice envelope. Replace with useAudioLevel(micStream | ttsAudio).
  const audible = (state === "listening" && !muted) || state === "speaking";
  React.useEffect(() => {
    if (!audible) return setLevel(0);
    let raf = 0;
    const start = performance.now();
    const tick = (now: number) => {
      const s = (now - start) / 1000;
      const phrase = (Math.sin(s * 0.7) + 1) / 2;
      const syl = Math.max(0, Math.sin(s * 6.2)) * 0.7 + Math.max(0, Math.sin(s * 9.1 + 1)) * 0.3;
      setLevel(Math.round((0.3 + phrase * syl * 0.7) * 100) / 100);
      raf = requestAnimationFrame(tick);
    };
    raf = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(raf);
  }, [audible]);

  // Call timer.
  React.useEffect(() => {
    if (!running) return;
    const id = setInterval(() => setSeconds((s) => s + 1), 1000);
    return () => clearInterval(id);
  }, [running]);

  const end = () => {
    setRunning(false);
    setState("idle");
    setMsgs([]);
    setSeconds(0);
    setMuted(false);
  };
  // Tap the orb: start a new call, or interrupt the assistant.
  const press = () => {
    if (!running) {
      setRunning(true);
      return;
    }
    if (state === "speaking" || state === "thinking") {
      setMsgs((m) => m.map((x) => ({ ...x, shown: x.words.length })));
      setState("idle");
      setTurn((n) => n + 1);
    }
  };

  const clock = `${String(Math.floor(seconds / 60)).padStart(2, "0")}:${String(seconds % 60).padStart(2, "0")}`;

  return (
    <div className="flex h-full min-h-[600px] w-full flex-col overflow-hidden bg-background text-foreground">
      <header className="flex items-center justify-between px-6 pt-5 font-mono text-[11px] uppercase tracking-[.14em] text-muted-foreground sm:px-10">
        <span>Assistant</span>
        <span className="tabular-nums">{running ? clock : "Call ended"}</span>
      </header>

      <main className="flex min-h-0 flex-1 flex-col justify-center">
      {/* Transcript: newest at the bottom, older lines fade out at the top. */}
      <div
        aria-live="polite"
        className="mx-auto flex h-[clamp(150px,36vh,300px)] w-full max-w-xl shrink-0 flex-col justify-end gap-4 overflow-hidden px-6 pb-2 pt-8"
        style={{ maskImage: "linear-gradient(to bottom, transparent, #000 48px)", WebkitMaskImage: "linear-gradient(to bottom, transparent, #000 48px)" }}
      >
        {!running && <p className="text-center text-sm text-muted-foreground">Tap the orb to start a new call.</p>}
        {msgs.map((m) => (
          <p
            key={m.id}
            className={
              m.who === "you"
                ? "max-w-[85%] self-end text-right text-sm text-muted-foreground sm:text-base"
                : "max-w-[90%] self-start text-lg font-medium leading-snug sm:text-2xl"
            }
          >
            {m.words.slice(0, m.shown).join(" ")}
          </p>
        ))}
      </div>

      {/* Padding leaves room for the rings, which grow to 1.5× the orb while it speaks. */}
      <div className="flex shrink-0 items-center justify-center" style={{ padding: "calc(min(170px, 40vw, 24vh) * 0.3) 0" }}>
        <VoiceOrb
          state={state}
          level={level}
          size="min(170px, 40vw, 24vh)"
          onPress={press}
          aria-label={!running ? "Start a call" : state === "speaking" || state === "thinking" ? "Interrupt" : "Voice assistant"}
        />
      </div>
      </main>

      <footer className="flex shrink-0 justify-center gap-3 pb-6">
        <button
          type="button"
          aria-pressed={muted}
          disabled={!running}
          onClick={() => setMuted((v) => !v)}
          className="rounded-full bg-muted px-5 py-2.5 text-sm font-medium transition-colors hover:bg-muted/70 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-40"
        >
          {muted ? "Unmute" : "Mute"}
        </button>
        <button
          type="button"
          disabled={!running}
          onClick={end}
          className="rounded-full bg-red-500 px-5 py-2.5 text-sm font-medium text-white transition-colors hover:bg-red-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-40"
        >
          End
        </button>
      </footer>
    </div>
  );
}

More by Darya

View all
  • Orbit Timeline

    Orbit Timeline

    48
  • Grain Light Card

    Grain Light Card

    28
  • ASCII Voice Orb

    ASCII Voice Orb

    11
  • Impact Flow

    Impact Flow