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.