Darya

ASCII Voice Orb

Component 11

About

Voice-assistant orb drawn in ASCII characters on one canvas. Four states: idle breathes, listening pulls ripples in, speaking pushes them out with the voice, thinking scans and decodes. Feed level (0–1) from a mic or TTS. Set color, charset, char size. No deps.

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

Code

How ASCII 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 { AsciiOrb, type AsciiOrbState, type AsciiOrbCharset } from "@/components/ui/ascii-voice-orb";

// Each preset has a light tint for dark backgrounds and a deep tone for light ones.
const COLORS: Record<string, { dark: string; light: string }> = {
  Ice: { dark: "#a3d6ff", light: "#195b91" },
  Amber: { dark: "#ffc775", light: "#975700" },
  Mint: { dark: "#93f0ca", light: "#0b7652" },
  Lilac: { dark: "#c9b6ff", light: "#5a3dc2" },
  Graphite: { dark: "#eceff3", light: "#1a1d23" },
};

const SEG_BTN =
  "flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-semibold text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring aria-pressed:bg-background aria-pressed:text-foreground aria-pressed:shadow-sm";

function Row({ label, children }: { label: string; children: React.ReactNode }) {
  return (
    <div className="flex flex-wrap items-center gap-2">
      <span className="w-20 shrink-0 text-xs text-muted-foreground">{label}</span>
      {children}
    </div>
  );
}

function Segmented<T extends string>({
  label,
  value,
  options,
  onChange,
}: {
  label: string;
  value: T;
  options: { value: T; label: React.ReactNode }[];
  onChange: (v: T) => void;
}) {
  return (
    <Row label={label}>
      <div role="group" aria-label={label} className="flex flex-wrap gap-0.5 rounded-lg bg-muted p-0.5">
        {options.map((o) => (
          <button key={o.value} type="button" aria-pressed={value === o.value} onClick={() => onChange(o.value)} className={SEG_BTN}>
            {o.label}
          </button>
        ))}
      </div>
    </Row>
  );
}

function Slider({
  label,
  value,
  min,
  max,
  step,
  onChange,
  format = (v) => v.toFixed(2),
}: {
  label: string;
  value: number;
  min: number;
  max: number;
  step: number;
  onChange: (v: number) => void;
  format?: (v: number) => string;
}) {
  return (
    <label className="flex items-center gap-2 text-xs text-muted-foreground">
      <span className="w-20 shrink-0">{label}</span>
      <input
        type="range"
        min={min}
        max={max}
        step={step}
        value={value}
        onChange={(e) => onChange(parseFloat(e.target.value))}
        className="w-32 max-w-[180px] flex-1 accent-primary"
      />
      <span className="w-10 font-mono tabular-nums">{format(value)}</span>
    </label>
  );
}

function Group({ title, children }: { title: string; children: React.ReactNode }) {
  return (
    <section className="flex min-w-0 flex-col gap-2.5">
      <h3 className="mb-0.5 border-b border-border pb-1.5 font-mono text-[11px] font-semibold uppercase tracking-[.12em] text-muted-foreground">{title}</h3>
      {children}
    </section>
  );
}

export default function AsciiOrbDemo() {
  const [state, setState] = React.useState<AsciiOrbState>("speaking");
  const [theme, setTheme] = React.useState<"dark" | "light">("dark");
  const [preset, setPreset] = React.useState("Ice");
  const [charset, setCharset] = React.useState<AsciiOrbCharset>("classic");
  const [fontSize, setFontSize] = React.useState(11);
  const [speed, setSpeed] = React.useState(1);
  const [level, setLevel] = React.useState(0);
  const [simulate, setSimulate] = React.useState(true);
  const [open, setOpen] = React.useState(true);

  // Fake voice envelope: phrases made of syllables. In a real app, feed `level` from an AnalyserNode.
  const active = state === "listening" || state === "speaking";
  React.useEffect(() => {
    if (!simulate || !active) return;
    let raf = 0;
    const start = performance.now();
    const tick = (now: number) => {
      const t = (now - start) / 1000;
      const phrase = (Math.sin(t * 0.7) + 1) / 2;
      const syl = Math.max(0, Math.sin(t * 6.2)) * 0.7 + Math.max(0, Math.sin(t * 9.1 + 1)) * 0.3;
      setLevel(Math.round(phrase * syl * 100) / 100);
      raf = requestAnimationFrame(tick);
    };
    raf = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(raf);
  }, [simulate, active]);

  const dark = theme === "dark";
  const color = dark ? COLORS[preset].dark : COLORS[preset].light;

  return (
    <div className="flex h-full min-h-[600px] w-full flex-col overflow-hidden bg-background text-foreground">
      {/* Stage */}
      <div
        className="relative flex min-h-0 flex-1 items-center justify-center overflow-hidden p-6 transition-colors duration-300"
        style={{ background: dark ? "#0b0d11" : "#ffffff" }}
      >
        <AsciiOrb
          state={state}
          level={level}
          color={color}
          charset={charset}
          fontSize={fontSize}
          speed={speed}
          size="min(380px, 70vw, 46vh)"
          glow={dark ? 0.5 : 0}
        />
      </div>

      {/* Controls */}
      <div className="max-h-[60vh] shrink-0 overflow-y-auto border-t border-border px-4 pb-3 pt-3">
        <div className="mb-2 flex items-center justify-between">
          <div className="text-sm font-bold">
            ASCII Voice Orb <span className="ml-2 font-mono text-[11px] font-normal text-muted-foreground">demo controls</span>
          </div>
          <button
            type="button"
            aria-expanded={open}
            onClick={() => setOpen((v) => !v)}
            className="rounded-lg border border-border bg-muted px-2.5 py-1.5 text-xs font-semibold transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
          >
            {open ? "Hide controls" : "Show controls"}
          </button>
        </div>

        {open && (
          <div className="grid grid-cols-1 gap-x-7 gap-y-4 md:grid-cols-3">
            <Group title="Preview">
              <Segmented
                label="State"
                value={state}
                onChange={setState}
                options={[
                  { value: "idle", label: "Idle" },
                  { value: "listening", label: "Listening" },
                  { value: "thinking", label: "Thinking" },
                  { value: "speaking", label: "Speaking" },
                ]}
              />
              <Slider
                label="Voice level"
                value={level}
                min={0}
                max={1}
                step={0.01}
                onChange={(v) => {
                  setSimulate(false);
                  setLevel(v);
                }}
              />
              <label className="flex items-center gap-1.5 pl-[5.5rem] text-xs text-muted-foreground">
                <input type="checkbox" checked={simulate} onChange={(e) => setSimulate(e.target.checked)} className="accent-primary" />
                Simulate speech
              </label>
              <Segmented
                label="Theme"
                value={theme}
                onChange={setTheme}
                options={[
                  { value: "dark", label: <><span className="size-3 rounded-full border border-border" style={{ background: "#0b0d11" }} />Dark</> },
                  { value: "light", label: <><span className="size-3 rounded-full border border-border" style={{ background: "#ffffff" }} />Light</> },
                ]}
              />
            </Group>

            <Group title="Appearance">
              <Segmented
                label="Color"
                value={preset}
                onChange={setPreset}
                options={Object.entries(COLORS).map(([name, c]) => ({
                  value: name,
                  label: (
                    <span
                      role="img"
                      aria-label={name}
                      title={name}
                      className="block size-3.5 rounded-full border border-border"
                      style={{ background: dark ? c.dark : c.light }}
                    />
                  ),
                }))}
              />
              <Segmented
                label="Chars"
                value={charset}
                onChange={setCharset}
                options={[
                  { value: "classic", label: "Classic" },
                  { value: "dense", label: "Dense" },
                  { value: "binary", label: "Binary" },
                ]}
              />
              <Slider label="Char size" value={fontSize} min={7} max={14} step={1} onChange={setFontSize} format={(v) => `${v}px`} />
            </Group>

            <Group title="Motion">
              <Slider label="Speed" value={speed} min={0.3} max={2} step={0.1} onChange={setSpeed} format={(v) => v.toFixed(1) + "×"} />
            </Group>
          </div>
        )}
      </div>
    </div>
  );
}

More by Darya

View all
  • Orbit Timeline

    Orbit Timeline

    49
  • Grain Light Card

    Grain Light Card

    28
  • Impact Flow

    Impact Flow

  • Voice Orb

    Voice Orb