Skip to content
Star on GitHub
ConversationVoice orb
Style
Accent
Sound

Built by DesignMe, free to use under the MIT licence.

Expert design and dev support for startups and enterprises.

Book a call

Voice orb

A hands-free voice chat, with an orb that shows who is talking. Free and MIT licensed, as HTML or a typed React component, in two styles with light and dark mode.

$npx designme add voice-orb
Ready0:00

Talk to the agent about Pricing v4

You're muted. Unmute so the agent can hear you.

01

Install

Add it to your project with the CLI.

HTML, CSS and JavaScript

$npx designme add voice-orb

React

$npx designme add voice-orb --react

The CLI copies the source into your project, so you own it and can change anything. The HTML version lands in ./designme with the shared core and a demo.html. The React version lands in src/components/designme and needs React 19; it works in Next.js, Vite and Astro.

02

About

What it is for and what it does.

Voice orb is the screen for a hands-free voice chat with the agent. An orb in the middle swells with whoever is talking, in the accent colour while it listens to you and violet while the agent speaks, with what was said written under it word by word. Below sit mute and end, and someone who talks while muted is told so, with a button to unmute.

  • When people talk to the agent instead of typing, and need to see that it is listening.
  • When it should be clear at a glance whose turn it is: yours, the agent's, or the agent thinking.
  • When people mute often, and should find out straight away when they talk into a muted microphone.
  • When the chat should end with a record of it, like a saved transcript.
03

Usage

Import it and drive it from your own state.

Import

import { VoiceOrb } from '@/components/designme/voice-orb/VoiceOrb';

Example

The demo above as a React component, 85 lines you can copy.

'use client';
// The animated preview from designme.dev, rebuilt from the React component: an example of driving it from your own state.
import { useEffect, useRef, useState } from 'react';
import { VoiceOrb, type VoiceState } from './VoiceOrb';
import { useReducedMotion } from '../lib/hooks';
import { useStory, useWait } from '../lib/useScript';

/* ---------- Voice orb: start, mute while the agent answers, talk while muted, unmute and go on, then end ---------- */
// what is said, turn by turn. The demo plays it as a script: it never uses the microphone or makes a sound
const TURNS: [speaker: string, line: string][] = [
  ['You', 'Make the yearly price the default on the pricing page.'],
  ['Agent', 'Done. Yearly is now the default on all three plans in Pricing v4.'],
  ['You', 'Show the yearly saving in green too.'],
  ['Agent', 'Added. The saving shows in green under each yearly price.']
];

export default function VoiceOrbDemo() {
  const ref = useRef<HTMLDivElement>(null);
  const wait = useWait();
  const [state, setState] = useState<VoiceState>('idle');
  const [muted, setMuted] = useState(false);
  const [notice, setNotice] = useState(false);
  const [caption, setCaption] = useState<{ speaker: string; text: string; id: string }>();
  const [secs, setSecs] = useState(0);
  const [level, setLevel] = useState(0);
  const run = useRef(0), talking = useRef(false), mutedNow = useRef(false), lv = useRef(0);
  const reduce = useReducedMotion();
  mutedNow.current = muted;

  // a made-up level: a voice-like wobble while someone talks, a faint idle while it listens
  useEffect(() => {
    let raf = 0;
    const tick = (t: number) => {
      const to = talking.current ? .5 + .28 * Math.sin(t / 90) * Math.sin(t / 233) + .16 * Math.sin(t / 57) : state === 'listening' && !muted ? .05 + .03 * Math.sin(t / 420) : 0;
      lv.current += (Math.max(0, to) - lv.current) * .18; setLevel(lv.current); raf = requestAnimationFrame(tick);
    };
    // with reduced motion the level holds still; the setting is followed as it changes
    if (!reduce) raf = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(raf);
  }, [state, muted, reduce]);
  // the clock runs from start to end, whatever happens between
  const live = state !== 'idle' && state !== 'ended';
  useEffect(() => {
    if (!live) return;
    const t = setInterval(() => setSecs(s => s + 1), 1000);
    return () => clearInterval(t);
  }, [live]);

  const say = async (speaker: string, line: string, ms: number, id: number, n: number) => {
    const words = line.split(' '); talking.current = true;
    for (let k = 1; k <= words.length; k++) { if (id !== run.current) return; setCaption({ speaker, text: words.slice(0, k).join(' '), id: String(n) }); await wait(ms); }
    talking.current = false;
  };
  const start = async () => {
    const id = ++run.current; setSecs(0); setCaption(undefined);
    for (let n = 0; n < TURNS.length; n++) {
      const [speaker, line] = TURNS[n];
      if (speaker === 'You') {
        setState('listening'); await wait(1200); if (id !== run.current) return;
        // they start talking while muted: say so, and wait for them to unmute
        if (mutedNow.current) { setNotice(true); while (mutedNow.current) { await wait(100); if (id !== run.current) return; } setNotice(false); await wait(400); }
        await say(speaker, line, 230, id, n); if (id !== run.current) return;
        setState('thinking'); await wait(1300);
      } else { setState('speaking'); await say(speaker, line, 190, id, n); if (id !== run.current) return; setState('listening'); }
    }
  };
  const end = () => { run.current++; talking.current = false; setNotice(false); setState('ended'); };

  // the preview's story, on the same beats as the site's
  useStory(ref, async ({ wait, press }) => {
    await wait(2100); press('.dm-vor-go');
    await wait(7000); press('.dm-vor-mute');
    await wait(4200); press('.dm-vor-mw .dm-pill');
    await wait(6700); press('.dm-vor-end');
  });

  return (
    <div ref={ref} style={{ width: '100%' }}>
      <VoiceOrb state={state} level={level} muted={muted} onMutedChange={m => { setMuted(m); if (!m) setNotice(false); }} mutedNotice={notice}
        caption={caption} hint="Talk to the agent about Pricing v4" elapsed={secs}
        onStart={() => void start()} onEnd={end} onRestart={() => { setMuted(false); void start(); }} />
    </div>
  );
}
04

Props

Everything VoiceOrb takes.

PropTypeWhat it does
staterequiredVoiceState
levelnumberHow loud whoever is talking is, 0 to 1. The orb swells with it. Feed it from your audio, or from a made-up curve
mutedbooleanYour microphone is off. Listening shows as Muted, and the mute button is pressed
onMutedChange(muted: boolean) => void
mutedNoticebooleanShows "You're muted" with an Unmute button, for when someone talks while muted
caption{ speaker: string; text: string; id?: string }What was said last and who said it. Each word fades in as it arrives; a new `id` (or speaker) starts a new line
hintstringShown before anything is said, like "Talk to the agent about Pricing v4"
elapsednumberSeconds since the chat started, shown top right
onStart() => void
onEnd() => void
onRestart() => voidStart again, from the row that shows once the chat has ended
endedDetailstringThe quiet line under "Voice chat ended". Defaults to the length and "Transcript saved"
labels{ ready?: string; listening?: string; thinking?: string; speaking?: string; muted?: string; ended?: string; start?: string; end?: string; mute?: string; unmute?: string; mutedNotice?: string; endedTitle?: string; restart?: string }
classNamestring
05

Code

The full source, as the CLI installs it.

VoiceOrb.tsxReact · 247 lines
'use client';
import { useEffect, useRef, useState, type CSSProperties } from 'react';
import { cx } from '../lib/cx';
import { Icon } from '../lib/icons';
import { useReducedMotion } from '../lib/hooks';
import { Fold, Swap, Tip } from '../lib/motion';
import '../designme.css';
import './voice-orb.css';

/** idle (not started), listening, thinking, speaking (the agent) or ended */
export type VoiceState = 'idle' | 'listening' | 'thinking' | 'speaking' | 'ended';

export interface VoiceOrbProps {
  state: VoiceState;
  /** How loud whoever is talking is, 0 to 1. The orb swells with it. Feed it from your audio, or from a made-up curve */
  level?: number;
  /** Your microphone is off. Listening shows as Muted, and the mute button is pressed */
  muted?: boolean;
  onMutedChange?: (muted: boolean) => void;
  /** Shows "You're muted" with an Unmute button, for when someone talks while muted */
  mutedNotice?: boolean;
  /** What was said last and who said it. Each word fades in as it arrives; a new `id` (or speaker) starts a new line */
  caption?: { speaker: string; text: string; id?: string };
  /** Shown before anything is said, like "Talk to the agent about Pricing v4" */
  hint?: string;
  /** Seconds since the chat started, shown top right */
  elapsed?: number;
  onStart?: () => void;
  onEnd?: () => void;
  /** Start again, from the row that shows once the chat has ended */
  onRestart?: () => void;
  /** The quiet line under "Voice chat ended". Defaults to the length and "Transcript saved" */
  endedDetail?: string;
  labels?: { ready?: string; listening?: string; thinking?: string; speaking?: string; muted?: string; ended?: string; start?: string; end?: string; mute?: string; unmute?: string; mutedNotice?: string; endedTitle?: string; restart?: string };
  className?: string;
}

const fmt = (s: number) => `${Math.floor(s / 60)}:${String(Math.floor(s) % 60).padStart(2, '0')}`;

/** A hands-free voice chat: an orb that swells with whoever is talking, the accent colour for you and violet for the agent, what was
    said under it, and mute and end below. It draws what you pass; it never opens the microphone itself. */
export function VoiceOrb({
  state, level = 0, muted = false, onMutedChange, mutedNotice = false, caption, hint, elapsed = 0, onStart, onEnd, onRestart, endedDetail, labels = {}, className
}: VoiceOrbProps) {
  const L = { ready: 'Ready', listening: 'Listening', thinking: 'Thinking', speaking: 'Speaking', muted: 'Muted', ended: 'Ended', start: 'Start talking', end: 'End', mute: 'Mute', unmute: 'Unmute', mutedNotice: "You're muted. Unmute so the agent can hear you.", endedTitle: 'Voice chat ended', restart: 'Start again', ...labels };
  // muted only changes how listening looks: the agent can still think and speak
  const m = muted && state === 'listening' ? 'muted' : state;
  const status = { idle: L.ready, listening: L.listening, thinking: L.thinking, speaking: L.speaking, muted: L.muted, ended: L.ended }[m];
  const on = state !== 'idle' && state !== 'ended';

  return (
    <div data-dm="" className={className} style={{ width: '100%' }}>
      <div className={cx('dm-vor dm-panel', on && 'dm-on')} data-m={m}>
        <div className="dm-vor-top"><span className="dm-vor-dot" /><Swap className="dm-vor-st">{status}</Swap><span className="dm-vor-t dm-num dm-muted">{fmt(elapsed)}</span></div>
        <div className="dm-vor-orb" aria-hidden="true" style={{ '--dm-lv': Math.max(0, Math.min(1, level)).toFixed(3) } as CSSProperties}><i className="dm-vor-pc" /><i className="dm-vor-core"><OrbLight level={level} /></i></div>
        <div className="dm-vor-cap" aria-live="polite">
          <Swap className="dm-vor-who dm-muted">{caption?.speaker ?? ''}</Swap>
          {caption
            ? <p className="dm-vor-tx" key={caption.id ?? caption.speaker}>{caption.text.split(' ').filter(Boolean).map((w, i) => <span key={i} className="dm-w">{w} </span>)}</p>
            : <p className="dm-vor-tx dm-hint">{hint}</p>}
        </div>
        <Fold open={mutedNotice && muted && on} className="dm-vor-mf">
          <div className="dm-vor-mw"><p><Icon name="alert" size="xs" /><span>{L.mutedNotice}</span></p><button type="button" className="dm-pill dm-sm" onClick={() => onMutedChange?.(false)}>{L.unmute}</button></div>
        </Fold>
        {state === 'ended' ? (
          <div className="dm-vor-done dm-enter">
            <Icon name="ok" />
            <span className="dm-t">{L.endedTitle}<span className="dm-muted">{endedDetail ?? `${fmt(elapsed)} · Transcript saved`}</span></span>
            {onRestart && <button type="button" className="dm-pill dm-sm" onClick={onRestart}>{L.restart}</button>}
          </div>
        ) : (
          <div className="dm-vor-f">
            <button type="button" className="dm-pill dm-primary dm-sm dm-vor-go" onClick={onStart}><Icon name="mic" size="xs" />{L.start}</button>
            <Tip label={muted ? L.unmute : L.mute}><button type="button" className="dm-ib dm-fill dm-vor-mute" aria-label={L.mute} aria-pressed={muted} onClick={() => onMutedChange?.(!muted)}><Icon name="mic" /></button></Tip>
            <span className="dm-sp" />
            <button type="button" className="dm-pill dm-sm dm-vor-end" onClick={onEnd}>{L.end}</button>
          </div>
        )}
      </div>
    </div>
  );
}

/** The lit glass sphere, with light flowing inside it in the colours of whoever is talking, faster and brighter as they get
    louder. Where no canvas can draw the core keeps its plain gradient; with reduced motion it is one still frame, drawn again
    when the speaker changes */
function OrbLight({ level }: { level: number }) {
  const ref = useRef<HTMLCanvasElement>(null), lv = useRef(level), reduce = useReducedMotion();
  const [on, setOn] = useState(false);
  lv.current = Math.max(0, Math.min(1, level));
  useEffect(() => {
    const cv = ref.current, box = cv?.closest<HTMLElement>('.dm-vor'); if (!cv || !box) return;
    const s = orbShader(cv); if (!s) return;
    setOn(true);
    const dot = box.querySelector('.dm-vor-dot')!, pc = box.querySelector('.dm-vor-pc')!;
    // the two colours are read a few times a second, not every frame: reading them makes the browser recheck the page's styles
    let cols: [string, string] | null = null, colsAt = -1e9;
    const draw = (ms: number) => { const now = performance.now(); if (!cols || now - colsAt > 250) { cols = [getComputedStyle(dot).backgroundColor, getComputedStyle(pc).backgroundColor]; colsAt = now; } s.paint(ms, lv.current, cols[0], cols[1]); };
    let raf = 0, seen = true, flow = 0, last = 0, dead = false;
    const io = new IntersectionObserver(e => { seen = e[0].isIntersecting; }); io.observe(cv);
    const mo = new MutationObserver(() => setTimeout(() => { if (!dead) { cols = null; draw(flow); } }, 450));
    if (reduce) { draw(0); mo.observe(box, { attributes: true, attributeFilter: ['data-m'] }); }
    else {
      const tick = (t: number) => { flow += Math.min(50, t - (last || t)) * (1 + lv.current * 2.5); last = t; if (seen) draw(flow); raf = requestAnimationFrame(tick); };
      draw(0); raf = requestAnimationFrame(tick);
    }
    // the context is let go once the canvas has left the page, not when an effect merely runs again on the same canvas
    return () => { dead = true; cancelAnimationFrame(raf); io.disconnect(); mo.disconnect(); setTimeout(() => { if (!cv.isConnected) s.lose(); }); };
  }, [reduce]);
  return <canvas ref={ref} className={cx('dm-vor-gl', on && 'dm-on')} />;
}

// the glass sphere, as a fragment shader: the site's own source, copied in by scripts/sync.mjs (edit soft-2/index.html, then
// sync). It is lit as a ball: normals from the sphere, a soft key light from the top left with a deeper, shifted hue past the
// terminator toward the bottom right, light gathered through the glass on the far side, a fresnel edge, a broad sheen and a
// small sharp highlight. Inside, the view bends as it enters the glass and the light is read at three depths along it: deep
// pools of the two colours and a third hue swirl slowly, liquid light flows through the middle and two thin ribbons turn the
// other way near the surface. The level speeds the flow, brightens the heart, makes the rim wobble a little and the highlight
// shimmer. At its widest the sphere still sits inside the canvas, and nothing is drawn outside it
// <orb-frag>
const FRAG = `#ifdef GL_FRAGMENT_PRECISION_HIGH
precision highp float;
#else
precision mediump float;
#endif
uniform vec2 R;uniform float T,L;uniform vec3 A0,B0;
float h(vec2 p){return fract(sin(dot(p,vec2(127.1,311.7)))*43758.5453);}
float n(vec2 p){vec2 i=floor(p),f=fract(p);f=f*f*(3.-2.*f);return mix(mix(h(i),h(i+vec2(1,0)),f.x),mix(h(i+vec2(0,1)),h(i+1.),f.x),f.y);}
float fbm(vec2 p){float v=0.,a=.5;for(int i=0;i<4;i++){v+=a*n(p);p=mat2(1.6,1.2,-1.2,1.6)*p+vec2(1.7,9.2);a*=.5;}return v;}
mat2 rot(float a){float c=cos(a),s=sin(a);return mat2(c,s,-s,c);}
vec3 tn(vec3 c){float mx=max(c.r,max(c.g,c.b)),d=mx-min(c.r,min(c.g,c.b));c*=max(1.,.5/max(mx,.01));mx=max(mx,.5);d=mx-min(c.r,min(c.g,c.b));return max(mx-(mx-c)*mix(1.,clamp(.55*mx/max(d,.001),1.,3.),smoothstep(.03,.12,d)),0.);}
vec3 hue(vec3 c,float a){vec3 k=vec3(.57735);float co=cos(a);return c*co+cross(k,c)*sin(a)+k*dot(k,c)*(1.-co);}
void main(){
  vec3 A=tn(A0),B=tn(B0),C=hue(A,mix(-.9,1.4,smoothstep(-.1,.1,A.b-A.g)));
  vec2 u=(gl_FragCoord.xy*2.-R)/R.y;
  float t=T*.001,an=atan(u.y,u.x);
  float w=(.002+.008*L)*(sin(3.*an+t*1.9)+.6*sin(5.*an-t*2.7)+.4*sin(2.*an-t*1.1));
  float rad=.955*(1.+.008*sin(t*.9)+w);
  vec2 q=u/rad;float r=length(q),e=3./(R.y*rad);
  float a=1.-smoothstep(1.-e,1.,r);
  if(a<=0.){gl_FragColor=vec4(0.);return;}
  float z=sqrt(max(1.-r*r,0.));
  vec3 N=vec3(q,z);
  N.xy+=(vec2(n(q*5.+t*1.6),n(q*5.-t*1.3+7.))-.5)*(.03+.12*L);N=normalize(N);
  vec3 S=vec3(q,z),D=refract(vec3(0,0,-1),N,.72),P1=S+D*z*.35,P2=S+D*z*.9,P3=S+D*z*1.5;
  float s=t*.22;
  vec3 d1=P3-vec3(.55*sin(s*1.3),.5*cos(s*1.1),.3*sin(s*.7)),d2=P3-vec3(.55*cos(s*.9+2.),.5*sin(s*1.2+1.),.3*cos(s*.8+3.)),d3=P3-vec3(.5*sin(s*1.6+4.),.5*cos(s*1.4+5.),.3*sin(s+1.));
  float g1=exp(-dot(d1,d1)*2.6),g2=exp(-dot(d2,d2)*2.6),g3=exp(-dot(d3,d3)*3.2);
  vec3 col=mix(mix(mix(mix(A,B,.4)*.55,B,g2*.8),C,g3*.7),A,g1*.95);
  vec2 m=rot(t*.09)*P2.xy*1.5,o=vec2(fbm(m+vec2(0.,t*.3)),fbm(m+vec2(5.2,1.3)-t*.25));
  float f=fbm(m+(o-.5)*(2.5+2.*L)+vec2(t*.12,-t*.08));
  col=mix(col,mix(mix(A,B,.5),vec3(1.),.15),smoothstep(.45,.8,f)*.5)*(.8+.35*smoothstep(.2,.6,f));
  vec2 k=rot(-t*.33)*P1.xy;
  float b1=k.y+.32*sin(k.x*2.4+t*1.4)+.12*sin(k.x*5.3-t*2.2)+(o.x-.5)*.5,b2=k.x-.3*sin(k.y*2.1-t*1.1)+.4;
  col+=mix(mix(A,C,.35),vec3(1.),.35)*(exp(-b1*b1*40.)+.6*exp(-b2*b2*55.))*(.25+.35*L)*z;
  col+=mix(A,vec3(1.),.4)*exp(-dot(P2.xy,P2.xy)*3.)*(.08+.3*L);
  vec3 lk=normalize(vec3(-.5,.62,.6)),dk=hue(col,.35);
  col=mix(dk*dk*.6,col*1.06,smoothstep(-.55,.75,dot(N,lk)));
  col+=mix(B,vec3(1.),.5)*pow(1.-z,1.6)*smoothstep(.1,.95,dot(q/max(r,1e-3),-normalize(lk.xy)))*.55;
  col=mix(col,mix(vec3(.06),vec3(1.),smoothstep(-.3,.9,N.y*.7-N.x*.4)),pow(1.-N.z,3.)*.5);
  float nh=max(dot(N,normalize(lk+vec3(0,0,1))),0.);
  col+=pow(nh,700.)*(1.2+.8*L*sin(t*9.+q.x*8.))+pow(nh,60.)*.25+pow(nh,8.)*.1;
  gl_FragColor=vec4(clamp(col,0.,1.)*a,a);
}`;
// </orb-frag>

/** Draws the sphere on the orb's canvas: one draw a frame, at most 2 device pixels a CSS pixel, and nothing made anew each
    frame. Where WebGL is missing it is drawn in a few gradients on a 2D canvas instead. Returns paint(ms of flow, level 0 to 1,
    colour, second colour) and lose(), or null when neither canvas can draw */
function orbShader(cv: HTMLCanvasElement) {
  // a computed colour to 0 to 1 channels. Mid-transition it can come as oklab() or color(), so a 1px canvas reads it. Only a
  // colour that changed is read, a few times a second at most
  const px = document.createElement('canvas').getContext('2d', { willReadFrequently: true })!; px.canvas.width = px.canvas.height = 1;
  const rgb = (s: string, o: Float32Array) => { px.clearRect(0, 0, 1, 1); px.fillStyle = '#000'; px.fillStyle = s; px.fillRect(0, 0, 1, 1); const d = px.getImageData(0, 0, 1, 1).data; o[0] = d[0] / 255; o[1] = d[1] / 255; o[2] = d[2] / 255; };
  const ta = new Float32Array(3), tb = new Float32Array(3), ca = new Float32Array(3), cb = new Float32Array(3);
  let sa = '', sb = '', w = 0, at = -1e9, prev = -1e9;
  // the canvas's size in device pixels, checked a few times a second
  const fit = (now: number) => { if (w && now - at < 300) return false; at = now; const n = Math.round(cv.clientWidth * Math.min(2, devicePixelRatio || 1)) || 1; if (n === w) return false; w = cv.width = cv.height = n; return true; };
  // a new colour is glided to over a few frames; after a pause (a still frame, or back on screen) it is taken at once
  const tint = (a: string, b: string, now: number, snap: boolean) => {
    if (a !== sa) { sa = a; rgb(a, ta); } if (b !== sb) { sb = b; rgb(b, tb); }
    const k = snap || now - prev > 200 ? 1 : Math.min(1, (now - prev) / 110); prev = now;
    for (let i = 0; i < 3; i++) { ca[i] += (ta[i] - ca[i]) * k; cb[i] += (tb[i] - cb[i]) * k; }
  };
  const gl = cv.getContext('webgl', { premultipliedAlpha: true, antialias: false, powerPreference: 'low-power' });
  if (gl) {
    const sh = (type: number, src: string) => { const s = gl.createShader(type)!; gl.shaderSource(s, src); gl.compileShader(s); return s; };
    const pr = gl.createProgram()!;
    gl.attachShader(pr, sh(gl.VERTEX_SHADER, 'attribute vec2 p;void main(){gl_Position=vec4(p,0.,1.);}'));
    gl.attachShader(pr, sh(gl.FRAGMENT_SHADER, FRAG));
    gl.linkProgram(pr);
    const lose = () => { gl.getExtension('WEBGL_lose_context')?.loseContext(); };
    if (!gl.getProgramParameter(pr, gl.LINK_STATUS)) { lose(); return null; }
    gl.useProgram(pr);
    gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer()); gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]), gl.STATIC_DRAW);
    const p = gl.getAttribLocation(pr, 'p'); gl.enableVertexAttribArray(p); gl.vertexAttribPointer(p, 2, gl.FLOAT, false, 0, 0);
    const U = (k: string) => gl.getUniformLocation(pr, k), uR = U('R'), uT = U('T'), uL = U('L'), uA = U('A0'), uB = U('B0');
    return {
      paint(ms: number, lv: number, a: string, b: string) {
        const now = performance.now(); if (fit(now)) gl.viewport(0, 0, w, w);
        tint(a, b, now, false);
        gl.uniform2f(uR, w, w); gl.uniform1f(uT, ms); gl.uniform1f(uL, lv); gl.uniform3fv(uA, ca); gl.uniform3fv(uB, cb);
        gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
      },
      lose
    };
  }
  const x = cv.getContext('2d'); if (!x) return null;
  // the shader's colours: dark ones lifted, pale ones given more chroma, and a third hue turned round the grey axis toward
  // the warm side (blue and violet toward magenta, lime toward amber)
  const tn = (o: Float32Array, d: Float32Array) => { const lift = Math.max(1, .5 / Math.max(.01, o[0], o[1], o[2])), mx = Math.max(o[0], o[1], o[2]) * lift, dd = mx - Math.min(o[0], o[1], o[2]) * lift, s = Math.min(1, Math.max(0, (dd - .03) / .09)), k = 1 + (Math.min(3, Math.max(1, .55 * mx / Math.max(dd, .001))) - 1) * s * s * (3 - 2 * s); for (let i = 0; i < 3; i++) d[i] = Math.max(0, mx - (mx - o[i] * lift) * k); };
  const hue = (o: Float32Array, d: Float32Array, ang: number) => { const co = Math.cos(ang), si = Math.sin(ang) * .57735, m = (o[0] + o[1] + o[2]) / 3 * (1 - co); d[0] = o[0] * co + (o[2] - o[1]) * si + m; d[1] = o[1] * co + (o[0] - o[2]) * si + m; d[2] = o[2] * co + (o[1] - o[0]) * si + m; };
  const A = new Float32Array(3), B = new Float32Array(3), C = new Float32Array(3), M = new Float32Array(3);
  const css = (o: Float32Array, al: number, f = 1) => `rgba(${[0, 1, 2].map(i => Math.round(Math.min(1, Math.max(0, o[i] * f)) * 255)).join(',')},${al})`;
  // every gradient is made for a sphere of radius 1 at the origin, and placed by the transform as it is drawn
  const grad = (cx: number, cy: number, r: number, stops: (number | string)[]) => { const g = x.createRadialGradient(cx, cy, 0, cx, cy, r); for (let i = 0; i < stops.length; i += 2) g.addColorStop(stops[i] as number, stops[i + 1] as string); return g; };
  const shade = grad(-.35, -.4, 1.6, [0, 'rgba(255,255,255,.16)', .45, 'rgba(0,0,0,0)', 1, 'rgba(0,0,0,.5)']),
    rim = grad(0, 0, 1, [.78, 'rgba(255,255,255,0)', 1, 'rgba(255,255,255,.28)']),
    sheen = grad(-.32, -.38, .6, [0, 'rgba(255,255,255,.22)', 1, 'rgba(255,255,255,0)']),
    spec = grad(-.4, -.46, .14, [0, 'rgba(255,255,255,.95)', 1, 'rgba(255,255,255,0)']);
  let g: { base: string; a: CanvasGradient; b: CanvasGradient; c: CanvasGradient; core: CanvasGradient } | null = null, mid = 0, rad = 0;
  const put = (fill: string | CanvasGradient, dx: number, dy: number, sz: number) => { x.setTransform(rad * sz, 0, 0, rad * sz, mid + rad * dx, mid + rad * dy); x.fillStyle = fill; x.fillRect(-1, -1, 2, 2); };
  return {
    paint(ms: number, lv: number, a: string, b: string) {
      const now = performance.now(); fit(now);
      // the colour gradients are made again only when a colour changes
      if (!g || a !== sa || b !== sb) {
        tint(a, b, now, true); tn(ca, A); tn(cb, B); hue(A, C, A[2] > A[1] ? 1.4 : -.9);
        for (let i = 0; i < 3; i++) M[i] = (A[i] + B[i]) / 2;
        g = { base: css(M, 1, .7), a: grad(0, 0, 1, [0, css(A, .95), 1, css(A, 0)]), b: grad(0, 0, 1, [0, css(B, .95), 1, css(B, 0)]), c: grad(0, 0, 1, [0, css(C, .55), 1, css(C, 0)]), core: grad(0, 0, 1, [0, css(A, 1, 1.4), 1, css(A, 0)]) };
      }
      const t = ms * .001, s = t * .22; mid = w / 2; rad = mid * .955 * (1 + .008 * Math.sin(t * .9));
      x.setTransform(1, 0, 0, 1, 0, 0); x.clearRect(0, 0, w, w);
      x.save(); x.beginPath(); x.arc(mid, mid, rad, 0, Math.PI * 2); x.clip();
      x.fillStyle = g.base; x.fillRect(0, 0, w, w);
      put(g.a, .55 * Math.sin(s * 1.3), -.5 * Math.cos(s * 1.1), 1.1);
      put(g.b, .55 * Math.cos(s * .9 + 2), -.5 * Math.sin(s * 1.2 + 1), 1.1);
      put(g.c, .5 * Math.sin(s * 1.6 + 4), -.5 * Math.cos(s * 1.4 + 5), .9);
      x.globalAlpha = .15 + .45 * lv; put(g.core, 0, 0, .7);
      x.globalAlpha = 1; put(shade, 0, 0, 1); put(rim, 0, 0, 1); put(sheen, 0, 0, 1);
      x.globalAlpha = .85 + .15 * lv * Math.sin(t * 9); put(spec, 0, 0, 1);
      x.restore();
    },
    lose() {}
  };
}
voice-orb.cssReact · 61 lines
/* Generated from designme.dev by scripts/sync.mjs. Edit the site, then sync. */
/* ---------- Voice orb ---------- */
/* a hands-free voice chat: an orb that swells with whoever is talking, the accent colour for you and violet for the agent, what
   was said under it, and mute and end below. Talking while muted says so, the way calls do */
.dm-vor{position:relative;width:100%;max-width:380px;margin:0 auto;padding:var(--dm-s-4);display:flex;flex-direction:column;gap:var(--dm-s-3);--dm-oc:var(--dm-faint);--dm-oc2:var(--dm-accent)}
/* --oc is whoever is talking, --oc2 the second colour that flows through the orb with it. Before the chat starts the dot is
   grey and the orb is a quiet glass with a little of the accent inside it */
/* the colours are mixed in oklch, so the step between them stays bright (lime to violet passes through teal, not grey) */
.dm-vor[data-m="listening"]{--dm-oc:var(--dm-accent);--dm-oc2:color-mix(in oklch,var(--dm-pl) 45%,var(--dm-accent))}
.dm-vor[data-m="speaking"]{--dm-oc:var(--dm-pl);--dm-oc2:color-mix(in oklch,var(--dm-accent) 45%,var(--dm-pl))}
.dm-vor[data-m="thinking"]{--dm-oc:color-mix(in oklch,var(--dm-accent) 50%,var(--dm-pl));--dm-oc2:var(--dm-accent)}
.dm-vor[data-m="muted"]{--dm-oc:color-mix(in srgb,var(--dm-muted) 55%,var(--dm-faint));--dm-oc2:var(--dm-faint)}
.dm-vor-top{display:flex;align-items:center;gap:var(--dm-s-2);font-size:13px}
.dm-vor-dot{width:8px;height:8px;border-radius:50%;background:var(--dm-oc);transition:background .4s}
.dm-vor[data-m="listening"] .dm-vor-dot,.dm-vor[data-m="speaking"] .dm-vor-dot{animation:dm-vor-pulse 1.4s var(--dm-ease-io) infinite}
@keyframes dm-vor-pulse{0%,100%{box-shadow:0 0 0 0 color-mix(in srgb,var(--dm-oc) 35%,transparent)}50%{box-shadow:0 0 0 5px transparent}}
.dm-vor-t{margin-left:auto;font-size:12.5px}
.dm-vor-orb{position:relative;width:112px;height:112px;margin:var(--dm-s-2) auto;--dm-lv:0}
.dm-vor-orb i{position:absolute;inset:0;border-radius:50%}
/* round in Blueprint too, where every other corner is square: like the turning loaders, its shape is what it says */
.dm-vor .dm-vor-orb i{border-radius:50%!important}
/* the sphere: nothing reaches past its edge, no glow or ring, so it never needs room around it. It swells with the level up to
   its box and no further, so a pulse is never cut off. Before the canvas draws (or where it can't) it is a plain gradient
   circle, lit from the top left and deeper at the foot */
.dm-vor-core{overflow:hidden;background-color:color-mix(in srgb,var(--dm-oc) 80%,var(--dm-surface));background-image:radial-gradient(circle at 34% 28%,rgba(255,255,255,.4),rgba(255,255,255,0) 60%);box-shadow:inset 0 -10px 20px color-mix(in srgb,var(--dm-oc) 55%,#000 10%);transform:scale(calc(.9 + var(--dm-lv) * .1));transition:background-color .4s}
/* the lit glass sphere, drawn on a canvas the size of the orb. Its edge breathes a little inside the box, so the plain
   circle behind it goes once it draws */
.dm-vor-gl{position:absolute;inset:0;width:100%;height:100%;border-radius:50%;opacity:0;transition:opacity .4s}
.dm-vor-gl.dm-on{opacity:1}
.dm-vor-core:has(>.dm-vor-gl.dm-on){background:none;box-shadow:none}
/* reads the second colour as it eases from one speaker to the next */
.dm-vor-pc{visibility:hidden;background:var(--dm-oc2);transition:background .4s}
.dm-vor-cap,.dm-vor-mf,.dm-vor-f{position:relative}
.dm-vor[data-m="thinking"] .dm-vor-core{animation:dm-vor-br 1.6s var(--dm-ease-io) infinite alternate}
@keyframes dm-vor-br{from{transform:scale(.9)}to{transform:scale(.96)}}
.dm-vor-cap{min-height:88px;display:flex;flex-direction:column;gap:2px;text-align:center}
.dm-vor-who{font-size:12px;min-height:18px}
.dm-vor-tx{margin:0;font-size:14px;line-height:1.6;color:var(--dm-fg)}
.dm-vor-tx.dm-hint{color:var(--dm-muted);font-size:13px}
.dm-vor-tx .dm-w{animation:dm-vor-w .45s var(--dm-ease) both}
@keyframes dm-vor-w{from{opacity:0;filter:blur(3px)}to{opacity:1;filter:none}}
.dm-vor-mw{display:flex;align-items:center;gap:10px;padding:var(--dm-s-2) var(--dm-s-2) var(--dm-s-2) var(--dm-s-3);border-radius:var(--dm-r-in);background:var(--dm-warn-bg)}
.dm-vor-mw p{margin:0;flex:1;display:flex;gap:var(--dm-s-2);font-size:13px;line-height:1.45;color:var(--dm-fg)}
.dm-vor-mw p svg{color:var(--dm-warn);flex:none;margin-top:3px}
.dm-vor-mw .dm-pill,.dm-vor-done .dm-pill{background:var(--dm-surface);color:var(--dm-fg);box-shadow:var(--dm-shadow)}
.dm-vor-f{display:flex;align-items:center;gap:var(--dm-s-2);min-height:32px}
.dm-vor-f .dm-sp{flex:1}
.dm-vor:not(.dm-on) .dm-vor-f{justify-content:center}
.dm-vor:not(.dm-on) .dm-vor-mute,.dm-vor:not(.dm-on) .dm-vor-end,.dm-vor:not(.dm-on) .dm-vor-f .dm-sp,.dm-vor.dm-on .dm-vor-go{display:none}
.dm-vor-mute{position:relative}
.dm-vor-mute[aria-pressed="true"]{background:var(--dm-bad-bg);color:var(--dm-bad);box-shadow:none}
/* the slash across the microphone once it is muted */
.dm-vor-mute::after{content:"";position:absolute;left:50%;top:50%;width:1.5px;height:18px;margin:-9px 0 0 -.75px;border-radius:2px;background:currentColor;transform:rotate(-45deg) scaleY(0);transition:transform .25s var(--dm-ease)}
.dm-vor-mute[aria-pressed="true"]::after{transform:rotate(-45deg) scaleY(1)}
.dm-vor-end{background:var(--dm-bad);color:var(--dm-on-ink)}
@media (hover:hover) and (pointer:fine){.dm-vor-end:hover{background:color-mix(in srgb,var(--dm-bad) 86%,#000)}}
.dm-vor-done{display:flex;align-items:center;gap:var(--dm-s-3);padding:10px 10px 10px var(--dm-s-3);border-radius:var(--dm-r-in);background:var(--dm-chip);font-size:13.5px}
.dm-vor-done>svg{color:var(--dm-fg2);flex:none}
.dm-vor-done .dm-t{flex:1;min-width:0;display:flex;flex-direction:column}
.dm-vor-done .dm-t span{font-size:13px}
voice-orb.htmlHTML · 17 lines
<div class="card" data-c="voice-orb">
  <div class="stage-in">
    <div class="vor panel" data-m="idle">
      <div class="vor-top"><span class="vor-dot"></span><span class="vor-st">Ready</span><span class="vor-t num muted">0:00</span></div>
      <div class="vor-orb" aria-hidden="true"><i class="vor-pc"></i><i class="vor-core"><canvas class="vor-gl"></canvas></i></div>
      <div class="vor-cap" aria-live="polite"><span class="vor-who muted"></span><p class="vor-tx hint">Talk to the agent about Pricing v4</p></div>
      <div class="fold shut vor-mf"><div><div class="vor-mw"><p><svg class="i xs"><use href="#i-alert"/></svg><span>You're muted. Unmute so the agent can hear you.</span></p><button class="pill sm vor-un">Unmute</button></div></div></div>
      <div class="vor-f">
        <button class="pill primary sm vor-go"><svg class="i xs"><use href="#i-mic"/></svg>Start talking</button>
        <button class="ib fill vor-mute" aria-label="Mute" aria-pressed="false" data-tip="Mute"><svg class="i s"><use href="#i-mic"/></svg></button>
        <span class="sp"></span>
        <button class="pill sm vor-end">End</button>
      </div>
    </div>
  </div>
</div>
voice-orb.cssHTML · 60 lines
/* ---------- Voice orb ---------- */
/* a hands-free voice chat: an orb that swells with whoever is talking, the accent colour for you and violet for the agent, what
   was said under it, and mute and end below. Talking while muted says so, the way calls do */
.vor{position:relative;width:100%;max-width:380px;margin:0 auto;padding:var(--s-4);display:flex;flex-direction:column;gap:var(--s-3);--oc:var(--faint);--oc2:var(--accent)}
/* --oc is whoever is talking, --oc2 the second colour that flows through the orb with it. Before the chat starts the dot is
   grey and the orb is a quiet glass with a little of the accent inside it */
/* the colours are mixed in oklch, so the step between them stays bright (lime to violet passes through teal, not grey) */
.vor[data-m="listening"]{--oc:var(--accent);--oc2:color-mix(in oklch,var(--pl) 45%,var(--accent))}
.vor[data-m="speaking"]{--oc:var(--pl);--oc2:color-mix(in oklch,var(--accent) 45%,var(--pl))}
.vor[data-m="thinking"]{--oc:color-mix(in oklch,var(--accent) 50%,var(--pl));--oc2:var(--accent)}
.vor[data-m="muted"]{--oc:color-mix(in srgb,var(--muted) 55%,var(--faint));--oc2:var(--faint)}
.vor-top{display:flex;align-items:center;gap:var(--s-2);font-size:13px}
.vor-dot{width:8px;height:8px;border-radius:50%;background:var(--oc);transition:background .4s}
.vor[data-m="listening"] .vor-dot,.vor[data-m="speaking"] .vor-dot{animation:vor-pulse 1.4s var(--ease-io) infinite}
@keyframes vor-pulse{0%,100%{box-shadow:0 0 0 0 color-mix(in srgb,var(--oc) 35%,transparent)}50%{box-shadow:0 0 0 5px transparent}}
.vor-t{margin-left:auto;font-size:12.5px}
.vor-orb{position:relative;width:112px;height:112px;margin:var(--s-2) auto;--lv:0}
.vor-orb i{position:absolute;inset:0;border-radius:50%}
/* round in Blueprint too, where every other corner is square: like the turning loaders, its shape is what it says */
.vor .vor-orb i{border-radius:50%!important}
/* the sphere: nothing reaches past its edge, no glow or ring, so it never needs room around it. It swells with the level up to
   its box and no further, so a pulse is never cut off. Before the canvas draws (or where it can't) it is a plain gradient
   circle, lit from the top left and deeper at the foot */
.vor-core{overflow:hidden;background-color:color-mix(in srgb,var(--oc) 80%,var(--surface));background-image:radial-gradient(circle at 34% 28%,rgba(255,255,255,.4),rgba(255,255,255,0) 60%);box-shadow:inset 0 -10px 20px color-mix(in srgb,var(--oc) 55%,#000 10%);transform:scale(calc(.9 + var(--lv) * .1));transition:background-color .4s}
/* the lit glass sphere, drawn on a canvas the size of the orb. Its edge breathes a little inside the box, so the plain
   circle behind it goes once it draws */
.vor-gl{position:absolute;inset:0;width:100%;height:100%;border-radius:50%;opacity:0;transition:opacity .4s}
.vor-gl.on{opacity:1}
.vor-core:has(>.vor-gl.on){background:none;box-shadow:none}
/* reads the second colour as it eases from one speaker to the next */
.vor-pc{visibility:hidden;background:var(--oc2);transition:background .4s}
.vor-cap,.vor-mf,.vor-f{position:relative}
.vor[data-m="thinking"] .vor-core{animation:vor-br 1.6s var(--ease-io) infinite alternate}
@keyframes vor-br{from{transform:scale(.9)}to{transform:scale(.96)}}
.vor-cap{min-height:88px;display:flex;flex-direction:column;gap:2px;text-align:center}
.vor-who{font-size:12px;min-height:18px}
.vor-tx{margin:0;font-size:14px;line-height:1.6;color:var(--fg)}
.vor-tx.hint{color:var(--muted);font-size:13px}
.vor-tx .w{animation:vor-w .45s var(--ease) both}
@keyframes vor-w{from{opacity:0;filter:blur(3px)}to{opacity:1;filter:none}}
.vor-mw{display:flex;align-items:center;gap:10px;padding:var(--s-2) var(--s-2) var(--s-2) var(--s-3);border-radius:var(--r-in);background:var(--warn-bg)}
.vor-mw p{margin:0;flex:1;display:flex;gap:var(--s-2);font-size:13px;line-height:1.45;color:var(--fg)}
.vor-mw p svg{color:var(--warn);flex:none;margin-top:3px}
.vor-mw .pill,.vor-done .pill{background:var(--surface);color:var(--fg);box-shadow:var(--shadow)}
.vor-f{display:flex;align-items:center;gap:var(--s-2);min-height:32px}
.vor-f .sp{flex:1}
.vor:not(.on) .vor-f{justify-content:center}
.vor:not(.on) .vor-mute,.vor:not(.on) .vor-end,.vor:not(.on) .vor-f .sp,.vor.on .vor-go{display:none}
.vor-mute{position:relative}
.vor-mute[aria-pressed="true"]{background:var(--bad-bg);color:var(--bad);box-shadow:none}
/* the slash across the microphone once it is muted */
.vor-mute::after{content:"";position:absolute;left:50%;top:50%;width:1.5px;height:18px;margin:-9px 0 0 -.75px;border-radius:2px;background:currentColor;transform:rotate(-45deg) scaleY(0);transition:transform .25s var(--ease)}
.vor-mute[aria-pressed="true"]::after{transform:rotate(-45deg) scaleY(1)}
.vor-end{background:var(--bad);color:var(--on-ink)}
@media (hover:hover) and (pointer:fine){.vor-end:hover{background:color-mix(in srgb,var(--bad) 86%,#000)}}
.vor-done{display:flex;align-items:center;gap:var(--s-3);padding:10px 10px 10px var(--s-3);border-radius:var(--r-in);background:var(--chip);font-size:13.5px}
.vor-done>svg{color:var(--fg2);flex:none}
.vor-done .t{flex:1;min-width:0;display:flex;flex-direction:column}
.vor-done .t span{font-size:13px}
voice-orb.jsHTML · 215 lines
// the glass sphere of Voice orb, as a fragment shader. It is lit as a ball: normals from the sphere, a soft key light from the
// top left with a deeper, shifted hue past the terminator toward the bottom right, light gathered through the glass on the
// far side, a fresnel edge that reflects a soft room, a broad sheen and a small sharp highlight. Inside, the view bends as
// it enters the glass and the light is read at three depths along it, so the layers shift against each other like things
// in a volume: deep pools of the two colours and a third hue swirl slowly, liquid light (noise bent by noise) flows through
// the middle, and two thin ribbons turn the other way near the surface. The level (L) speeds the flow up, bends the liquid
// further, brightens the heart, makes the rim wobble a little and the highlight shimmer. At its widest the sphere still sits
// inside the canvas, and nothing is drawn outside it. The React component reads this same source
const ORB_FRAG = `#ifdef GL_FRAGMENT_PRECISION_HIGH
precision highp float;
#else
precision mediump float;
#endif
uniform vec2 R;uniform float T,L;uniform vec3 A0,B0;
float h(vec2 p){return fract(sin(dot(p,vec2(127.1,311.7)))*43758.5453);}
float n(vec2 p){vec2 i=floor(p),f=fract(p);f=f*f*(3.-2.*f);return mix(mix(h(i),h(i+vec2(1,0)),f.x),mix(h(i+vec2(0,1)),h(i+1.),f.x),f.y);}
float fbm(vec2 p){float v=0.,a=.5;for(int i=0;i<4;i++){v+=a*n(p);p=mat2(1.6,1.2,-1.2,1.6)*p+vec2(1.7,9.2);a*=.5;}return v;}
mat2 rot(float a){float c=cos(a),s=sin(a);return mat2(c,s,-s,c);}
vec3 tn(vec3 c){float mx=max(c.r,max(c.g,c.b)),d=mx-min(c.r,min(c.g,c.b));c*=max(1.,.5/max(mx,.01));mx=max(mx,.5);d=mx-min(c.r,min(c.g,c.b));return max(mx-(mx-c)*mix(1.,clamp(.55*mx/max(d,.001),1.,3.),smoothstep(.03,.12,d)),0.);}
vec3 hue(vec3 c,float a){vec3 k=vec3(.57735);float co=cos(a);return c*co+cross(k,c)*sin(a)+k*dot(k,c)*(1.-co);}
void main(){
  vec3 A=tn(A0),B=tn(B0),C=hue(A,mix(-.9,1.4,smoothstep(-.1,.1,A.b-A.g)));
  vec2 u=(gl_FragCoord.xy*2.-R)/R.y;
  float t=T*.001,an=atan(u.y,u.x);
  float w=(.002+.008*L)*(sin(3.*an+t*1.9)+.6*sin(5.*an-t*2.7)+.4*sin(2.*an-t*1.1));
  float rad=.955*(1.+.008*sin(t*.9)+w);
  vec2 q=u/rad;float r=length(q),e=3./(R.y*rad);
  float a=1.-smoothstep(1.-e,1.,r);
  if(a<=0.){gl_FragColor=vec4(0.);return;}
  float z=sqrt(max(1.-r*r,0.));
  vec3 N=vec3(q,z);
  N.xy+=(vec2(n(q*5.+t*1.6),n(q*5.-t*1.3+7.))-.5)*(.03+.12*L);N=normalize(N);
  vec3 S=vec3(q,z),D=refract(vec3(0,0,-1),N,.72),P1=S+D*z*.35,P2=S+D*z*.9,P3=S+D*z*1.5;
  float s=t*.22;
  vec3 d1=P3-vec3(.55*sin(s*1.3),.5*cos(s*1.1),.3*sin(s*.7)),d2=P3-vec3(.55*cos(s*.9+2.),.5*sin(s*1.2+1.),.3*cos(s*.8+3.)),d3=P3-vec3(.5*sin(s*1.6+4.),.5*cos(s*1.4+5.),.3*sin(s+1.));
  float g1=exp(-dot(d1,d1)*2.6),g2=exp(-dot(d2,d2)*2.6),g3=exp(-dot(d3,d3)*3.2);
  vec3 col=mix(mix(mix(mix(A,B,.4)*.55,B,g2*.8),C,g3*.7),A,g1*.95);
  vec2 m=rot(t*.09)*P2.xy*1.5,o=vec2(fbm(m+vec2(0.,t*.3)),fbm(m+vec2(5.2,1.3)-t*.25));
  float f=fbm(m+(o-.5)*(2.5+2.*L)+vec2(t*.12,-t*.08));
  col=mix(col,mix(mix(A,B,.5),vec3(1.),.15),smoothstep(.45,.8,f)*.5)*(.8+.35*smoothstep(.2,.6,f));
  vec2 k=rot(-t*.33)*P1.xy;
  float b1=k.y+.32*sin(k.x*2.4+t*1.4)+.12*sin(k.x*5.3-t*2.2)+(o.x-.5)*.5,b2=k.x-.3*sin(k.y*2.1-t*1.1)+.4;
  col+=mix(mix(A,C,.35),vec3(1.),.35)*(exp(-b1*b1*40.)+.6*exp(-b2*b2*55.))*(.25+.35*L)*z;
  col+=mix(A,vec3(1.),.4)*exp(-dot(P2.xy,P2.xy)*3.)*(.08+.3*L);
  vec3 lk=normalize(vec3(-.5,.62,.6)),dk=hue(col,.35);
  col=mix(dk*dk*.6,col*1.06,smoothstep(-.55,.75,dot(N,lk)));
  col+=mix(B,vec3(1.),.5)*pow(1.-z,1.6)*smoothstep(.1,.95,dot(q/max(r,1e-3),-normalize(lk.xy)))*.55;
  col=mix(col,mix(vec3(.06),vec3(1.),smoothstep(-.3,.9,N.y*.7-N.x*.4)),pow(1.-N.z,3.)*.5);
  float nh=max(dot(N,normalize(lk+vec3(0,0,1))),0.);
  col+=pow(nh,700.)*(1.2+.8*L*sin(t*9.+q.x*8.))+pow(nh,60.)*.25+pow(nh,8.)*.1;
  gl_FragColor=vec4(clamp(col,0.,1.)*a,a);
}`;
// draws the sphere on Voice orb's canvas: one draw a frame, at most 2 device pixels a CSS pixel, and nothing made anew each
// frame. Where WebGL is missing it is drawn in a few gradients on a 2D canvas instead: the same pools of colour drifting under
// the same light, terminator, edge and highlight. Returns paint(ms of flow, level 0 to 1, colour, second colour), or null
// when neither canvas can draw. The context is let go when the component stops
function orbShader(cv, c){
  // a computed colour to 0 to 1 channels. Mid-transition it can come as oklab() or color(), so a 1px canvas reads it. Only a
  // colour that changed is read, a few times a second at most
  const px = document.createElement('canvas').getContext('2d', { willReadFrequently:true }); px.canvas.width = px.canvas.height = 1;
  const rgb = (s, o) => { px.clearRect(0, 0, 1, 1); px.fillStyle = '#000'; px.fillStyle = s; px.fillRect(0, 0, 1, 1); const d = px.getImageData(0, 0, 1, 1).data; o[0] = d[0] / 255; o[1] = d[1] / 255; o[2] = d[2] / 255; };
  const ta = new Float32Array(3), tb = new Float32Array(3), ca = new Float32Array(3), cb = new Float32Array(3);
  let sa = '', sb = '', w = 0, at = -1e9, prev = -1e9;
  // the canvas's size in device pixels, checked a few times a second
  const fit = now => { if (w && now - at < 300) return false; at = now; const n = Math.round(cv.clientWidth * Math.min(2, devicePixelRatio || 1)) || 1; if (n === w) return false; w = cv.width = cv.height = n; return true; };
  // a new colour is glided to over a few frames; after a pause (a still frame, or back on screen) it is taken at once
  const tint = (a, b, now, snap) => {
    if (a !== sa){ sa = a; rgb(a, ta); } if (b !== sb){ sb = b; rgb(b, tb); }
    const k = snap || now - prev > 200 ? 1 : Math.min(1, (now - prev) / 110); prev = now;
    for (let i = 0; i < 3; i++){ ca[i] += (ta[i] - ca[i]) * k; cb[i] += (tb[i] - cb[i]) * k; }
  };
  const gl = cv.getContext('webgl', { premultipliedAlpha:true, antialias:false, powerPreference:'low-power' });
  if (gl){
    const sh = (type, src) => { const s = gl.createShader(type); gl.shaderSource(s, src); gl.compileShader(s); return s; };
    const pr = gl.createProgram();
    gl.attachShader(pr, sh(gl.VERTEX_SHADER, 'attribute vec2 p;void main(){gl_Position=vec4(p,0.,1.);}'));
    gl.attachShader(pr, sh(gl.FRAGMENT_SHADER, ORB_FRAG));
    gl.linkProgram(pr);
    const lose = () => { const x = gl.getExtension('WEBGL_lose_context'); if (x) x.loseContext(); };
    if (!gl.getProgramParameter(pr, gl.LINK_STATUS)){ lose(); return null; }
    gl.useProgram(pr);
    gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer()); gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1,-1, 1,-1, -1,1, 1,1]), gl.STATIC_DRAW);
    const p = gl.getAttribLocation(pr, 'p'); gl.enableVertexAttribArray(p); gl.vertexAttribPointer(p, 2, gl.FLOAT, false, 0, 0);
    const U = k => gl.getUniformLocation(pr, k), uR = U('R'), uT = U('T'), uL = U('L'), uA = U('A0'), uB = U('B0');
    c.offs.push(lose);
    cv.classList.add('on');
    return (ms, lv, a, b) => {
      const now = performance.now(); if (fit(now)) gl.viewport(0, 0, w, w);
      tint(a, b, now, false);
      gl.uniform2f(uR, w, w); gl.uniform1f(uT, ms); gl.uniform1f(uL, lv); gl.uniform3fv(uA, ca); gl.uniform3fv(uB, cb);
      gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
    };
  }
  const x = cv.getContext('2d'); if (!x) return null;
  // the shader's colours: dark ones lifted, pale ones given more chroma, and a third hue turned round the grey axis toward
  // the warm side (blue and violet toward magenta, lime toward amber)
  const tn = (o, d) => { const lift = Math.max(1, .5 / Math.max(.01, o[0], o[1], o[2])), mx = Math.max(o[0], o[1], o[2]) * lift, dd = mx - Math.min(o[0], o[1], o[2]) * lift, s = Math.min(1, Math.max(0, (dd - .03) / .09)), k = 1 + (Math.min(3, Math.max(1, .55 * mx / Math.max(dd, .001))) - 1) * s * s * (3 - 2 * s); for (let i = 0; i < 3; i++) d[i] = Math.max(0, mx - (mx - o[i] * lift) * k); };
  const hue = (o, d, ang) => { const co = Math.cos(ang), si = Math.sin(ang) * .57735, m = (o[0] + o[1] + o[2]) / 3 * (1 - co); d[0] = o[0] * co + (o[2] - o[1]) * si + m; d[1] = o[1] * co + (o[0] - o[2]) * si + m; d[2] = o[2] * co + (o[1] - o[0]) * si + m; };
  const A = new Float32Array(3), B = new Float32Array(3), C = new Float32Array(3), M = new Float32Array(3);
  const css = (o, al, f = 1) => `rgba(${[0, 1, 2].map(i => Math.round(Math.min(1, Math.max(0, o[i] * f)) * 255)).join(',')},${al})`;
  // every gradient is made for a sphere of radius 1 at the origin, and placed by the transform as it is drawn
  const grad = (cx, cy, r, stops) => { const g = x.createRadialGradient(cx, cy, 0, cx, cy, r); for (let i = 0; i < stops.length; i += 2) g.addColorStop(stops[i], stops[i + 1]); return g; };
  const shade = grad(-.35, -.4, 1.6, [0, 'rgba(255,255,255,.16)', .45, 'rgba(0,0,0,0)', 1, 'rgba(0,0,0,.5)']),
    rim = grad(0, 0, 1, [.78, 'rgba(255,255,255,0)', 1, 'rgba(255,255,255,.28)']),
    sheen = grad(-.32, -.38, .6, [0, 'rgba(255,255,255,.22)', 1, 'rgba(255,255,255,0)']),
    spec = grad(-.4, -.46, .14, [0, 'rgba(255,255,255,.95)', 1, 'rgba(255,255,255,0)']);
  let g = null, mid = 0, rad = 0;
  const put = (fill, dx, dy, sz) => { x.setTransform(rad * sz, 0, 0, rad * sz, mid + rad * dx, mid + rad * dy); x.fillStyle = fill; x.fillRect(-1, -1, 2, 2); };
  cv.classList.add('on');
  return (ms, lv, a, b) => {
    const now = performance.now(); fit(now);
    // the colour gradients are made again only when a colour changes
    if (!g || a !== sa || b !== sb){
      tint(a, b, now, true); tn(ca, A); tn(cb, B); hue(A, C, A[2] > A[1] ? 1.4 : -.9);
      for (let i = 0; i < 3; i++) M[i] = (A[i] + B[i]) / 2;
      g = { base:css(M, 1, .7), a:grad(0, 0, 1, [0, css(A, .95), 1, css(A, 0)]), b:grad(0, 0, 1, [0, css(B, .95), 1, css(B, 0)]), c:grad(0, 0, 1, [0, css(C, .55), 1, css(C, 0)]), core:grad(0, 0, 1, [0, css(A, 1, 1.4), 1, css(A, 0)]) };
    }
    const t = ms * .001, s = t * .22; mid = w / 2; rad = mid * .955 * (1 + .008 * Math.sin(t * .9));
    x.setTransform(1, 0, 0, 1, 0, 0); x.clearRect(0, 0, w, w);
    x.save(); x.beginPath(); x.arc(mid, mid, rad, 0, Math.PI * 2); x.clip();
    x.fillStyle = g.base; x.fillRect(0, 0, w, w);
    put(g.a, .55 * Math.sin(s * 1.3), -.5 * Math.cos(s * 1.1), 1.1);
    put(g.b, .55 * Math.cos(s * .9 + 2), -.5 * Math.sin(s * 1.2 + 1), 1.1);
    put(g.c, .5 * Math.sin(s * 1.6 + 4), -.5 * Math.cos(s * 1.4 + 5), .9);
    x.globalAlpha = .15 + .45 * lv; put(g.core, 0, 0, .7);
    x.globalAlpha = 1; put(shade, 0, 0, 1); put(rim, 0, 0, 1); put(sheen, 0, 0, 1);
    x.globalAlpha = .85 + .15 * lv * Math.sin(t * 9); put(spec, 0, 0, 1);
    x.restore();
  };
}
DM.def('voice-orb', (r, c) => {
  const IC = { warn:I('alert','xs'), mic:I('mic','xs'), micm:I('mic') };
  r.innerHTML = r.innerHTML.replace(/\{(\w+)\}/g, (m, k) => IC[k] || m);
  const root = $('.vor', r), orb = $('.vor-orb', r), st = $('.vor-st', r), tm = $('.vor-t', r), who = $('.vor-who', r), tx = $('.vor-tx', r), mf = $('.vor-mf', r), f = $('.vor-f', r), mute = $('.vor-mute', r);
  // what is said, turn by turn. The demo plays it as a script: it never uses the microphone or makes a sound
  const TURNS = [['You', 'Make the yearly price the default on the pricing page.'], ['Agent', 'Done. Yearly is now the default on all three plans in Pricing v4.'], ['You', 'Show the yearly saving in green too.'], ['Agent', 'Added. The saving shows in green under each yearly price.']];
  const HINT = 'Talk to the agent about Pricing v4', LABEL = { idle:'Ready', listening:'Listening', thinking:'Thinking', speaking:'Speaking', muted:'Muted', ended:'Ended' };
  let mode = 'idle', muted = false, talking = false, said = 0, secs = 0, run = 0, done = null, lv = 0;
  const fmt = s => `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
  // muted only changes how listening looks: the agent can still think and speak
  const show = () => { const m = muted && mode === 'listening' ? 'muted' : mode; if (root.dataset.m !== m){ root.dataset.m = m; swap(st, LABEL[m]); } };
  const set = m => { mode = m; show(); };
  const notice = on => { mf.classList.toggle('shut', !on); mf.inert = !on; };
  notice(false);
  // centred in its stage at every moment: it glides up as the muted notice or the ended row opens, so it never sits high
  centre(c, root, () => root.offsetHeight);
  // the orb is a lit glass sphere with light flowing inside it in the colours of whoever is talking, faster and brighter as
  // they get louder (orbShader, above). Where no canvas can draw, the core keeps its plain gradient
  const paint = orbShader($('.vor-gl', orb), c), dot = $('.vor-dot', r), pc = $('.vor-pc', orb);
  let seen = true, flow = 0, last = 0;
  if (paint && 'IntersectionObserver' in window){ const io = new IntersectionObserver(e => { seen = e[0].isIntersecting; }); io.observe(orb); c.offs.push(() => io.disconnect()); }
  // the two colours are read a few times a second, not every frame: reading them makes the browser recheck the page's styles
  let cols = null, colsAt = -1e9;
  const draw = t => { if (!paint) return; const now = performance.now(); if (!cols || now - colsAt > 250){ cols = [getComputedStyle(dot).backgroundColor, getComputedStyle(pc).backgroundColor]; colsAt = now; } paint(t, lv, cols[0], cols[1]); };
  // a first frame straight away, so the sphere is there even while a pointer rests on the card and the loop holds
  draw(0);
  // the orb follows a level: a made-up voice while someone talks, a faint idle while it listens. With reduced motion it is
  // still: one frame, drawn again when the colours change. The setting is read every frame, so it can change mid-visit
  let still = false;
  c.loop(t => {
    if (REDUCE){ if (!still){ still = true; lv = 0; orb.style.setProperty('--lv', '0'); draw(0); } last = 0; return; }
    still = false;
    const to = talking ? .5 + .28 * Math.sin(t / 90) * Math.sin(t / 233) + .16 * Math.sin(t / 57) : mode === 'listening' && !muted ? .05 + .03 * Math.sin(t / 420) : 0;
    lv += (Math.max(0, to) - lv) * .18; orb.style.setProperty('--lv', lv.toFixed(3));
    flow += Math.min(50, t - (last || t)) * (1 + lv * 2.5); last = t;
    if (seen) draw(flow);
  });
  if (paint){ const mo = new MutationObserver(() => c.later(450, () => { cols = null; if (REDUCE) draw(0); })); mo.observe(root, { attributes:true, attributeFilter:['data-m'] }); c.offs.push(() => mo.disconnect()); }
  c.every(1000, () => { if (mode !== 'idle' && mode !== 'ended'){ secs++; tm.textContent = fmt(secs); } });
  async function say(name, line, ms, id){
    swap(who, name); tx.classList.remove('hint'); tx.innerHTML = ''; talking = true;
    for (const w of line.split(' ')){ if (id !== run) return; tx.insertAdjacentHTML('beforeend', `<span class="w">${w} </span>`); await c.wait(ms); }
    talking = false;
  }
  async function talk(id){
    for (const [name, line] of TURNS){
      if (name === 'You'){
        set('listening'); await c.wait(1200); if (id !== run) return;
        // they start talking while muted: say so, and wait for them to unmute
        if (muted){ notice(true); while (muted){ await c.wait(100); if (id !== run) return; } await c.wait(400); if (id !== run) return; }
        await say(name, line, 230, id); if (id !== run) return;
        set('thinking'); await c.wait(1300);
      } else { set('speaking'); await say(name, line, 190, id); if (id !== run) return; set('listening'); }
      said++;
    }
  }
  function start(){
    const id = ++run; said = 0; secs = 0; tm.textContent = fmt(0);
    if (done){ done.remove(); done = null; } f.hidden = false; root.classList.add('on');
    c.run(() => talk(id));
  }
  function setMuted(on){
    muted = on; mute.setAttribute('aria-pressed', on); mute.dataset.tip = on ? 'Unmute' : 'Mute';
    if (!on) notice(false); show();
  }
  $('.vor-go', r).onclick = start;
  mute.onclick = () => setMuted(!muted);
  $('.vor-un', r).onclick = () => setMuted(false);
  $('.vor-end', r).onclick = () => {
    run++; talking = false; notice(false); set('ended'); root.classList.remove('on'); f.hidden = true;
    done = html(`<div class="vor-done">${I('ok')}<span class="t">Voice chat ended<span class="muted">${fmt(secs)} · Transcript saved</span></span><button class="pill sm">Start again</button></div>`);
    root.appendChild(done); enter(done);
    $('button', done).onclick = () => { setMuted(false); swap(who, ''); tx.classList.add('hint'); tx.textContent = HINT; start(); };
  };
  // the preview's story: start, mute while the agent answers, talk while muted, unmute and go on, then end
  c.story(async s => {
    await s.beat(3); await s.tap($('.vor-go', r)); s.away();
    await s.until(() => mode === 'speaking'); await s.beat(3); await s.tap(mute); s.away();
    await s.until(() => !mf.classList.contains('shut')); await s.beat(4);
    await s.tap($('.vor-un', r)); s.away();
    await s.until(() => said === TURNS.length); await s.beat(3);
    await s.tap($('.vor-end', r)); s.away(); await s.beat(1);
  });
});
06

Questions

What people ask about it.

Does it use the microphone or play sound?
No. It draws the state, level and caption you pass. Your code records, transcribes and plays the agent's voice. The demo plays a written script and never asks for the microphone.
How is it different from Voice capture?
Voice capture records one message to check and fix before sending. Voice orb is a back-and-forth conversation, with the agent answering out loud.
How do I drive the orb from real audio?
Read the loudness from your audio, for example with a Web Audio AnalyserNode, scale it to 0 to 1, and pass it as level on each animation frame.
07

More in Conversation

Everything for asking, answering and replying, by text or by voice.

Live answerAnswers that stream in with numbered sources and next steps.
ComposerMessage input with attachments, what to work on, a mode and dictation.
Thread panelSwitch between agent threads and reply in place.
Artifact cardA file the agent made, with its versions, Open, Copy and Download.
Rich text editorA minimal editor with an inline AI menu that streams a suggestion to accept or discard.
Tool callEach tool the agent calls, with its input and output.
Voice captureTalk to the agent, then fix what it misheard before sending.
Answer action barCopy, regenerate, rate and listen to an answer from one row under it.
All 15 in Conversation →