ThreeOrb
ThreeOrb renders the JARVIS orb in WebGL with Three.js, in six visual designs, either as a
full-screen background layer or inside a container. It ships in its own entry point,
jarvis-react-ui/orb, so Three.js only loads when you use it. For a lightweight CSS version see
CssOrb.
import { ThreeOrb } from 'jarvis-react-ui/orb';
Installation
three is an optional peer dependency of jarvis-react-ui. Install it next to the library when
you use ThreeOrb or createOrb:
npm install three
ThreeOrb, createOrb and the OrbEngine type are not exported from jarvis-react-ui; import them
from jarvis-react-ui/orb.
Lazy loading
Load ThreeOrb with React.lazy and wrap it in Suspense, so Three.js is split into its own chunk
and fetched on first render. Changing state makes the particles transition to the new state;
working is shown as thinking.
import { Suspense, lazy, useState } from 'react';
import { Mono, StateSimulator } from 'jarvis-react-ui';
import type { AppOrbState } from 'jarvis-react-ui';
// Loads Three.js and the orb engine in their own chunk on first render.
const ThreeOrb = lazy(() => import('jarvis-react-ui/orb').then((m) => ({ default: m.ThreeOrb })));
export default function LazyLoaded() {
const [state, setState] = useState<AppOrbState>('idle');
return (
<>
<Suspense fallback={<Mono muted>Loading orb…</Mono>}>
{/* fill="container": fills the demo frame instead of the viewport. */}
<div className="absolute inset-0">
<ThreeOrb state={state} fill="container" />
</div>
</Suspense>
<StateSimulator
state={state}
onChange={setState}
position="inline"
className="relative z-10"
/>
</>
);
}
import { Suspense, lazy } from 'react';
const ThreeOrb = lazy(() => import('jarvis-react-ui/orb').then((m) => ({ default: m.ThreeOrb })));
<Suspense fallback={null}>
<ThreeOrb state="listening" />
</Suspense>;
Variants
variant picks the design. constellation (the default) is the original particle cloud; particle,
halo, signal, reactor and lattice are bloom-lit holographic designs, each loaded as its own
chunk the first time it is used. Try them in the playground: switch the design and the state, drag to
rotate and scroll to zoom, turn on the microphone (processed locally in your browser) or let the demo
cycle through the states.
import { Suspense, lazy, useEffect, useState } from 'react';
import { Button, Mono, Switch, useToast } from 'jarvis-react-ui';
import type { ThreeOrbVariant } from 'jarvis-react-ui/orb';
const ThreeOrb = lazy(() => import('jarvis-react-ui/orb').then((m) => ({ default: m.ThreeOrb })));
const VARIANTS: Record<ThreeOrbVariant, string> = {
constellation: 'The original orb: particle cloud with connecting lines (default).',
particle: 'Glowing core, particle shell, tilted orbital rings and shockwaves.',
halo: 'Particle orb with finer points, a faint inner shell and softer rings.',
signal: 'Particle orb with traveling light currents and clearer orbital arcs.',
reactor: 'Energy core, counter-rotating segmented rings, amber telemetry, audio iris.',
lattice: 'Faceted crystal core, geodesic lattice, tick rings and a 128-bar spectrum.',
};
const STATES = ['idle', 'listening', 'working', 'speaking'] as const;
type DemoState = (typeof STATES)[number];
export default function VariantPlayground() {
const { toast } = useToast();
const [variant, setVariant] = useState<ThreeOrbVariant>('particle');
const [state, setState] = useState<DemoState>('idle');
const [lowQuality, setLowQuality] = useState(false);
const [cycle, setCycle] = useState(false);
const [mic, setMic] = useState(false);
const [analyser, setAnalyser] = useState<AnalyserNode | null>(null);
// Demo cycle: next state every 5 s.
useEffect(() => {
if (!cycle) return undefined;
const id = window.setInterval(() => {
setState((s) => STATES[(STATES.indexOf(s) + 1) % STATES.length] ?? 'idle');
}, 5000);
return () => window.clearInterval(id);
}, [cycle]);
// Microphone → AnalyserNode (processed locally, nothing is sent).
useEffect(() => {
if (!mic) return undefined;
let ctx: AudioContext | null = null;
let stream: MediaStream | null = null;
let cancelled = false;
navigator.mediaDevices
.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true } })
.then((media) => {
stream = media;
if (cancelled) return;
ctx = new AudioContext();
const node = ctx.createAnalyser();
node.fftSize = 1024;
node.smoothingTimeConstant = 0.55;
ctx.createMediaStreamSource(media).connect(node);
setAnalyser(node);
setState('listening');
})
.catch(() => {
toast({ title: 'Microphone unavailable', variant: 'error' });
setMic(false);
});
return () => {
cancelled = true;
stream?.getTracks().forEach((track) => track.stop());
void ctx?.close();
setAnalyser(null);
};
}, [mic, toast]);
return (
<div className="relative flex h-full w-full flex-col">
<div className="absolute inset-0">
<Suspense fallback={<Mono muted>Loading orb…</Mono>}>
<ThreeOrb
state={state}
variant={variant}
quality={lowQuality ? 'low' : 'high'}
analyser={analyser}
fill="container"
interactive
/>
</Suspense>
</div>
<div className="pointer-events-none relative z-10 flex flex-wrap gap-2 p-3">
{(Object.keys(VARIANTS) as ThreeOrbVariant[]).map((v) => (
<Button
key={v}
size="sm"
variant={v === variant ? 'primary' : 'ghost'}
aria-pressed={v === variant}
onClick={() => setVariant(v)}
className="pointer-events-auto"
>
{v}
</Button>
))}
</div>
<div className="pointer-events-none relative z-10 mt-auto flex flex-col gap-3 p-3">
<Mono muted size="sm">
{VARIANTS[variant]} Drag to rotate, scroll to zoom.
</Mono>
<div className="flex flex-wrap items-center gap-2">
{STATES.map((s) => (
<Button
key={s}
size="sm"
variant={s === state ? 'secondary' : 'ghost'}
aria-pressed={s === state}
onClick={() => setState(s)}
className="pointer-events-auto"
>
{s}
</Button>
))}
<Switch
size="sm"
label="Mic"
checked={mic}
onCheckedChange={setMic}
className="pointer-events-auto"
/>
<Switch
size="sm"
label="Demo cycle"
checked={cycle}
onCheckedChange={setCycle}
className="pointer-events-auto"
/>
<Switch
size="sm"
label="Low quality"
checked={lowQuality}
onCheckedChange={setLowQuality}
className="pointer-events-auto"
/>
</div>
</div>
</div>
);
}
<div className="h-96">
<ThreeOrb state="listening" variant="reactor" fill="container" interactive />
</div>
The variant designs know four states. thinking and working show as working, follow_up as
listening.
Audio input
Pass a Web Audio AnalyserNode as analyser, for example from the microphone or from your TTS
playback. In listening and speaking the variant designs read its level and 16 frequency bands
(80 Hz – 8 kHz) every frame; without an analyser they animate with a simulated voice.
constellation reacts to the analyser in every state.
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const ctx = new AudioContext();
const analyser = ctx.createAnalyser();
analyser.fftSize = 1024;
analyser.smoothingTimeConstant = 0.55;
ctx.createMediaStreamSource(stream).connect(analyser);
<ThreeOrb state="listening" variant="lattice" analyser={analyser} />;
Quality and interaction
quality="low" renders the variant designs with fewer particles, pixel ratio 1 and half-resolution
bloom, for integrated or mobile GPUs. interactive lets the user rotate (drag) and zoom (scroll) the
variant designs; it also makes a viewport orb take pointer events, so use it with
fill="container" or when nothing has to be clicked behind the orb. Both are ignored by
constellation.
Canvas and layout
With fill="container" the orb fills its parent element, which needs a size. With the default
fill="viewport" the canvas is position: fixed at the top-left corner, sized to the window (it follows window
resizes), sits at z-index: 0 and ignores the pointer, so it works as a background layer behind
the HUD. Every variant renders on a transparent canvas: only the orb and its glow are drawn, so put
your background color or GridBackground behind it. In the orb slot
of HUDShell, the shell's grid, stars and horizon stay visible around the
orb. Inside an ancestor with a transform, the fixed canvas is positioned relative to that
ancestor instead of the viewport; this demo uses className="h-full! w-full!" to fit it into the
demo frame.
import { Suspense, lazy } from 'react';
import { HUDShell } from 'jarvis-react-ui';
const ThreeOrb = lazy(() => import('jarvis-react-ui/orb').then((m) => ({ default: m.ThreeOrb })));
export default function OverScene() {
return (
<HUDShell
orb={
<Suspense fallback={null}>
{/* The canvas is transparent: grid, stars and horizon stay visible. */}
<ThreeOrb state="idle" variant="particle" className="h-full! w-full!" />
</Suspense>
}
/>
);
}
Without React
createOrb(canvas, options) drives the same engine on any <canvas>. It returns an OrbEngine
with the full API, including mock audio and the follow-up ring, which ThreeOrb does not expose:
import { createOrb } from 'jarvis-react-ui/orb';
import type { OrbEngine } from 'jarvis-react-ui/orb';
const canvas = document.querySelector('canvas');
if (canvas) {
const orb: OrbEngine = createOrb(canvas, { alpha: true }); // alpha: transparent background
orb.setState('listening'); // 'idle' | 'listening' | 'thinking' | 'speaking' | 'follow_up'
orb.setAnalyser(analyserNode); // react to a Web Audio AnalyserNode, or null
orb.setMockMode('speaking'); // fake audio levels for previews; null turns it off
orb.setFollowUp(true, 12); // follow-up pulse and countdown ring (seconds left)
// Later: stop rendering and remove the resize listener.
orb.destroy();
}
The engine renders at window size and listens for window resizes.
The variant designs work the same way with createVariantOrb(variant, container, { quality }). It
resolves to an OrbRenderer whose canvas fills container (a ResizeObserver follows its size):
import { createVariantOrb } from 'jarvis-react-ui/orb';
const orb = await createVariantOrb('halo', container, { quality: 'high' });
orb.setState('speaking'); // 'idle' | 'listening' | 'working' | 'speaking'
orb.onFrame = () => orb.setAudio(level, bands); // per frame: level 0..1, 16 bands 0..1
orb.dispose(); // stop rendering, free GPU resources, remove the canvas
Accessibility
By default the orb is decorative: its root is aria-hidden="true" and, unless interactive, takes no
pointer events.
Show the assistant's state as text too, for example with a
StatusLabel. Pass aria-label when the orb itself should be announced;
the root then gets role="img" and that label. The orbs do not check prefers-reduced-motion; render
a static alternative such as <CssOrb> (whose CSS animations stop under reduced motion) when the
user asks for less motion.
API
| Prop | Type | Default | Description |
|---|---|---|---|
state (required) |
'idle' | 'listening' | 'thinking' | 'speaking' | 'follow_up' | 'working' |
Assistant state the orb animates to. Each design maps the states it doesn't have itself. | |
analyser |
AnalyserNode | null |
null |
Audio source, e.g. a microphone or TTS AnalyserNode; drives level and frequency bands. Without it, the variant designs use a simulated voice in listening and speaking. |
aria-label |
string |
Accessible name for the orb. When set, the root gets role="img" and this label; when omitted, the orb is decorative (aria-hidden="true"). |
|
className |
string |
Additional class names for the root element (the canvas for constellation in the viewport). |
|
fill |
'viewport' | 'container' |
'viewport' |
Where the orb renders: the whole viewport behind the UI, or its parent element. |
interactive |
boolean |
false |
Lets the user rotate (drag) and zoom (scroll) the variant designs. Ignored by constellation. |
quality |
'high' | 'low' |
'high' |
Render quality of the variant designs: low uses fewer particles, pixel ratio 1 and half-resolution bloom for weak GPUs. Ignored by constellation. |
variant |
'constellation' | 'particle' | 'halo' | 'signal' | 'reactor' | 'lattice' |
'constellation' |
Visual design. constellation is the original orb; particle, halo, signal, reactor and lattice are lazily loaded on first use. |