# Augur — AI Chat UI Component for React

> Assistant presence orb on 2D canvas: seeded noise-displaced blobs composited additively inside a glassy clipped sphere with Fresnel rim, soft specular and halo. Morphs per state — breathing idle, level-driven ripples while listening, folding bands and an orbit ring while thinking, pulses and a brighter core while speaking — blending over ~600ms. DPR-clamped, offscreen-paused, polite live-region announcements, a static lit frame per state under reduced motion.

- Page: https://crucibleui.com/components/ai/augur
- Category: [ai](https://crucibleui.com/components/ai)
- Tier: pro (one-time purchase, see https://crucibleui.com/pricing)
- Engine: canvas

## Install

```bash
npx shadcn@latest add @crucible/augur
```

Pro install requires adding the token-bearing `@crucible` registry to components.json (issued at https://crucibleui.com/account after purchase).

Import from `@/components/crucible/augur`.

## Props (`AugurProps`)

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `state?` | `AugurState` | `"idle"` | What the assistant is doing right now. The orb blends every visual parameter toward the new state over ~600ms — it never pops. - `idle` — slow ~0.2Hz breathing and a gentle internal swirl - `listening` — surface ripples driven by `level` - `thinking` — internal bands rotate faster and fold; an orbit ring spins in - `speaking` — radial pulses timed to `level` and a brighter core |
| `level?` | `number` | `0` | Live amplitude, 0–1 — mic input while listening, output volume while speaking. Smoothed internally with a fast attack and a soft decay, so you can feed raw analyser values every frame. Ignored in idle / thinking. |
| `size?` | `number` | `160` | Orb diameter in CSS px. The halo and orbit ring bleed ~30% past this box without affecting layout. |
| `colors?` | `[string, string, string] \| string[]` | `["#a78bfa", "#818cf8", "#67e8f9"]` | `[core, mid, rim]` — the inner glow, the body swirl, and the Fresnel rim / halo. Any CSS colors. Defaults to ion violet core → periwinkle → cyan rim. |
| `speed?` | `number` | `1` | Animation-speed multiplier for swirl, breathing and ring spin. 1 = default. |
| `intensity?` | `number` | `1` | Overall luminosity of glows, halo and rim, 0–2. Highlights stay soft at any value. |
| `paused?` | `boolean` | `false` | Freeze the orb at a static, fully lit frame of the current state. |
| `announce?` | `boolean` | `true` | Announce state changes ("Assistant is thinking") to screen readers via a visually hidden polite live region. |

Also accepts: `Omit<React.ComponentPropsWithoutRef<"div">, "children">`.

## Usage example

```tsx
"use client";

import * as React from "react";

import { Augur, type AugurState } from "@/components/crucible/augur";
import { useReducedMotion } from "@/hooks/use-reduced-motion";

const STATES: { value: AugurState; label: string }[] = [
  { value: "idle", label: "Idle" },
  { value: "listening", label: "Listening" },
  { value: "thinking", label: "Thinking" },
  { value: "speaking", label: "Speaking" },
];

/** Seeded sine partials — a deterministic, speech-like amplitude envelope. */
const PARTIALS = (() => {
  let a = 0x2f6b;
  const r = () => {
    a = (a + 0x6d2b79f5) | 0;
    let t = Math.imul(a ^ (a >>> 15), 1 | a);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
  return Array.from({ length: 5 }, (_, i) => ({
    f: 1.3 + i * 1.7 + r() * 1.4,
    ph: r() * Math.PI * 2,
    amp: 0.5 / (1 + i * 0.6),
  }));
})();

function synthLevel(t: number, state: AugurState): number {
  let s = 0;
  for (const p of PARTIALS) s += p.amp * Math.sin(t * p.f + p.ph);
  // Slow phrase envelope: bursts of "syllables" separated by soft pauses.
  const phrase = 0.5 + 0.5 * Math.sin(t * 0.9 + 1.2);
  const raw = 0.45 + s * 0.55;
  const lvl = raw * (0.35 + 0.65 * phrase) * (state === "speaking" ? 1.1 : 0.85);
  return Math.min(1, Math.max(0, lvl));
}

export default function AugurDemo() {
  const [state, setState] = React.useState<AugurState>("idle");
  const [level, setLevel] = React.useState(0);
  const reduced = useReducedMotion();
  const refs = React.useRef<(HTMLButtonElement | null)[]>([]);

  React.useEffect(() => {
    if (reduced || (state !== "listening" && state !== "speaking")) {
      setLevel(0);
      return;
    }
    let raf = 0;
    let lastPush = 0;
    const start = performance.now();
    const loop = (now: number) => {
      // ~30 updates/s is plenty — the orb smooths level internally.
      if (now - lastPush > 33) {
        lastPush = now;
        if (!document.hidden) setLevel(synthLevel((now - start) / 1000, state));
      }
      raf = requestAnimationFrame(loop);
    };
    raf = requestAnimationFrame(loop);
    return () => cancelAnimationFrame(raf);
  }, [state, reduced]);

  const onKeyDown = (e: React.KeyboardEvent, index: number) => {
    let next = index;
    if (e.key === "ArrowRight" || e.key === "ArrowDown") next = (index + 1) % STATES.length;
    else if (e.key === "ArrowLeft" || e.key === "ArrowUp") next = (index - 1 + STATES.length) % STATES.length;
    else if (e.key === "Home") next = 0;
    else if (e.key === "End") next = STATES.length - 1;
    else return;
    e.preventDefault();
    setState(STATES[next].value);
    refs.current[next]?.focus();
  };

  return (
    <div className="relative isolate flex h-full min-h-[360px] w-full items-center justify-center overflow-hidden rounded-xl bg-neutral-950">
      <div className="relative z-10 flex flex-col items-center gap-10 px-4 py-12">
        <Augur state={state} level={level} />

        <div className="flex flex-col items-center gap-3">
          <div
            role="radiogroup"
            aria-label="Assistant state"
            className="flex rounded-full border border-white/10 bg-white/[0.03] p-1"
          >
            {STATES.map((s, i) => {
              const checked = s.value === state;
              return (
                <button
                  key={s.value}
                  ref={(el) => {
                    refs.current[i] = el;
                  }}
                  type="button"
                  role="radio"
                  aria-checked={checked}
                  tabIndex={checked ? 0 : -1}
                  onClick={() => setState(s.value)}
                  onKeyDown={(e) => onKeyDown(e, i)}
                  className={
                    "rounded-full px-3 py-1.5 text-xs font-medium transition-colors outline-none focus-visible:ring-2 focus-visible:ring-[#a78bfa] sm:px-4 " +
                    (checked
                      ? "bg-white/10 text-white"
                      : "text-neutral-400 hover:text-neutral-200")
                  }
                >
                  {s.label}
                </button>
              );
            })}
          </div>
          <p className="text-xs text-neutral-500">Switch states to see the orb respond.</p>
        </div>
      </div>
    </div>
  );
}
```

## Accessibility & performance

Honors `prefers-reduced-motion` with a designed static frame or fade (never a blank box) and passes `className` through. Pauses offscreen and on hidden tabs, clamps device pixel ratio, and tears down its GL/canvas context on unmount.
