Skip to content
Brok UI

Loading…

No results

Orb

Open source

A decorative orb with four token-coloured looks via a `variant` prop: `liquid` (default) — a glowing voice-assistant sphere in hand-written WebGL1 (GLSL simplex-noise fragment shader, no three.js/textures) that breathes in reaction to agent state (idle/listening/talking/thinking), with a 2D-canvas fallback; `gradient` — a CSS-only "eclipse" sphere with a luminous breathing rim and soft halo; `particle` — a rotating fibonacci-sphere point cloud with depth-bright dots; `geometric` — a rotating latitude/longitude wireframe sphere with depth-based line opacity. Derives hue/saturation from --primary/--accent (or an explicit colors override), DPR-aware, reduced-motion safe.

Version
v1.5.2
Stability
stable
License
MIT
Related
Voice Dictator
Live Waveform
Bar Visualizer

Preview

Idle

Listening

Talking

previews.components.orb.default.blade.php Blade
<div class="w-full flex flex-wrap items-center justify-center gap-10">
    <div class="space-y-2 text-center">
        <x-ui.orb state="idle" class="w-32" />
        <p class="text-xs text-muted-foreground">{{ __('Idle') }}</p>
    </div>
    <div class="space-y-2 text-center">
        <x-ui.orb state="listening" class="w-32" />
        <p class="text-xs text-muted-foreground">{{ __('Listening') }}</p>
    </div>
    <div class="space-y-2 text-center">
        <x-ui.orb state="talking" class="w-32" />
        <p class="text-xs text-muted-foreground">{{ __('Talking') }}</p>
    </div>
</div>

Installation

terminal
php artisan ui:add orb

Note

This component ships an Alpine behavior module at resources/js/ui/orb.js. Import it once from your bundle so it registers on alpine:init:

resources/js/ui/index.js JS
import './orb.js';

Registry contract

php artisan ui:add orb writes only the files below. The CLI validates each file hash before writing and asks before it replaces a local change, unless you pass --force.

  • blade resources/views/components/ui/orb.blade.php
  • js resources/js/ui/orb.js
Registry dependencies
None — installs on its own.
Packages
composer: jml/brok:^0.2
npm: alpinejs

Use with AI

A brief for your coding agent: install command, usage, props, guidance and the rules. Copy it, or open a prompt about this component in an assistant.

orb.md
# Brok UI: Orb (`orb`)

A decorative orb with four token-coloured looks via a `variant` prop: `liquid` (default) — a glowing voice-assistant sphere in hand-written WebGL1 (GLSL simplex-noise fragment shader, no three.js/textures) that breathes in reaction to agent state (idle/listening/talking/thinking), with a 2D-canvas fallback; `gradient` — a CSS-only "eclipse" sphere with a luminous breathing rim and soft halo; `particle` — a rotating fibonacci-sphere point cloud with depth-bright dots; `geometric` — a rotating latitude/longitude wireframe sphere with depth-based line opacity. Derives hue/saturation from --primary/--accent (or an explicit colors override), DPR-aware, reduced-motion safe.

Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.

## Install

```bash
php artisan ui:add orb
```

## Usage

```blade
<div class="w-full flex flex-wrap items-center justify-center gap-10">
    <div class="space-y-2 text-center">
        <x-ui.orb state="idle" class="w-32" />
        <p class="text-xs text-muted-foreground">{{ __('Idle') }}</p>
    </div>
    <div class="space-y-2 text-center">
        <x-ui.orb state="listening" class="w-32" />
        <p class="text-xs text-muted-foreground">{{ __('Listening') }}</p>
    </div>
    <div class="space-y-2 text-center">
        <x-ui.orb state="talking" class="w-32" />
        <p class="text-xs text-muted-foreground">{{ __('Talking') }}</p>
    </div>
</div>
```

## Props

- `variant` (aurora|liquid|gradient|particle|geometric, default `aurora`)
- `state` (string, default `idle`)
- `label` (mixed|null, default `null`)

## Use when

- Use when listening, dictation, or audio feedback is central to the task and users benefit from temporal media controls.
- Giving a voice assistant a visual presence that reacts to idle, listening, talking and thinking.
- Decorating a hero or empty state with a token-coloured sphere.

## Avoid when

- Do not default to voice or audio UI when silent, scannable controls are faster or more accessible.
- Conveying information: the orb is decorative, and its state must also be given as text.
- Low-power pages; the liquid and aurora looks run a WebGL shader every frame while visible.

## Anti-patterns

- Autoplay with sound
- Audio as the only access to essential information

## Rules

- Use the `<brok:orb>` tag (or `<x-ui.orb>`) in Blade; do not rewrite the component.
- Prefer the documented props and variants over utility-class overrides; when a utility must win, use the `!` important modifier.
- Use semantic design tokens (`bg-primary`, `text-muted-foreground`), never raw colour utilities.
- Keep the `data-slot` attributes; they are the styling and test hooks.

## Links

- Docs: https://brokui.dev/docs/components/orb
- Registry JSON (files, props, contract): https://brokui.dev/r/open/orb.json

Working in Claude Code, Cursor or Codex? Give the agent the whole registry through the MCP server or the Brok UI skill.

Examples

variants.blade.php Blade
<div class="rounded-xl bg-foreground p-10">
    <div class="flex flex-wrap items-center justify-center gap-10">
        <div class="space-y-2 text-center">
            <x-ui.orb variant="aurora" state="talking" class="w-32" />
            <p class="text-xs text-background/70">{{ __('Aurora') }}</p>
        </div>
        <div class="space-y-2 text-center">
            <x-ui.orb variant="liquid" state="talking" class="w-32" />
            <p class="text-xs text-background/70">{{ __('Liquid') }}</p>
        </div>
        <div class="space-y-2 text-center">
            <x-ui.orb variant="gradient" class="w-32" />
            <p class="text-xs text-background/70">{{ __('Gradient') }}</p>
        </div>
        <div class="space-y-2 text-center">
            <x-ui.orb variant="particle" class="w-32" />
            <p class="text-xs text-background/70">{{ __('Particle') }}</p>
        </div>
        <div class="space-y-2 text-center">
            <x-ui.orb variant="geometric" class="w-32" />
            <p class="text-xs text-background/70">{{ __('Geometric') }}</p>
        </div>
    </div>
</div>

API

manifest knowledge + registry-derived coverage

Props

Props accepted by this component: name, type, default value and description.
Prop Type Default Description
variant aurora | liquid | gradient | particle | geometric aurora
state string idle
label mixed | null null

Slots

  • default —

Data slots

Stable hooks for CSS overrides and browser tests.

orb

Behavior

  • variant selects aurora, liquid, gradient, particle or geometric; an unknown value falls back to aurora.
  • state drives the breathing of the shader looks; the gradient look is CSS-only and the canvas looks fall back to 2D when WebGL is missing.
  • Colour derives from the primary and accent tokens, and animation stops under prefers-reduced-motion.
  • Installs a JavaScript behavior module when the registry item includes resources/js/ui files.
  • Declares registry capability flags: a11y, interactive, responsive, rtl, darkMode, localized, reducedMotion.

Guidance

Audio and voice interaction

Play, inspect, record, or dictate temporal audio.

Use when

  • Use when listening, dictation, or audio feedback is central to the task and users benefit from temporal media controls.
  • Giving a voice assistant a visual presence that reacts to idle, listening, talking and thinking.
  • Decorating a hero or empty state with a token-coloured sphere.

Avoid when

  • Do not default to voice or audio UI when silent, scannable controls are faster or more accessible.
  • Conveying information: the orb is decorative, and its state must also be given as text.
  • Low-power pages; the liquid and aurora looks run a WebGL shader every frame while visible.

Use instead

  • Silent scannable controls
  • Transcript or text input

Anti-patterns

  • Autoplay with sound
  • Audio as the only access to essential information
Anatomy
root
Theming hooks
orb

Accessibility

WCAG 2.2 AA Keyboard focus-visible RTL-ready Localized labels Dark mode
Keyboard
managed
Focus
none
  • The orb is role="img" with an accessible name, and its canvas is aria-hidden.
  • Motion is disabled under prefers-reduced-motion, leaving a static sphere.
  • Semantic HTML and a stable data-slot attribute for styling and scripting hooks.
  • Focus-visible rings use the ring token, so keyboard focus is always visible.
  • Disabled and invalid states are conveyed to assistive tech, not by color alone.
  • Targets WCAG 2.2 AA; verify contrast in light, dark, admin and customer surfaces in the preview.
  • Labels go through __() and layout uses logical properties (ms-*, text-start), so it mirrors under dir="rtl" — flip the preview to RTL to confirm.
  • Dark mode uses the same semantic tokens under the dark class; high contrast follows forced-color system tokens.

Livewire

Needs wire:ignore

Add wire:ignore to the component root because its behavior owns rendered DOM.

livewire-component.blade.php Blade
<div wire:ignore>
    <div class="w-full flex flex-wrap items-center justify-center gap-10">
        <div class="space-y-2 text-center">
            <x-ui.orb state="idle" class="w-32" />
            <p class="text-xs text-muted-foreground">{{ __('Idle') }}</p>
        </div>
        <div class="space-y-2 text-center">
            <x-ui.orb state="listening" class="w-32" />
            <p class="text-xs text-muted-foreground">{{ __('Listening') }}</p>
        </div>
        <div class="space-y-2 text-center">
            <x-ui.orb state="talking" class="w-32" />
            <p class="text-xs text-muted-foreground">{{ __('Talking') }}</p>
        </div>
    </div>
</div>

Source

The exact, editable files ui:add writes into your app. Previews render this same code; there are no preview-only components.

resources/views/components/ui/orb.blade.php Blade
@props([
    'variant' => 'aurora',
    'state' => 'idle',
    'label' => null,
])

@php
    $styles = require base_path(config('ui.component_path', 'resources/views/components/ui').'/_styles.php');
    $variant = $styles['normalizeVariant']($variant);
    // A decorative orb with four looks (all token-coloured — hue/saturation come
    // from the --primary / --accent design tokens at runtime, converted in JS, so
    // nothing raw-coloured ships here; pass `colors` to override):
    //
    //   aurora    — the DEFAULT: a pale, glassy sphere with two soft colour clouds
    //               drifting over a near-white body, a feathered edge and a slow
    //               breath (the ChatGPT voice look); same WebGL program as liquid.
    //   liquid    — a saturated, lit liquid sphere rendered with hand-written
    //               WebGL1 (a GLSL fragment shader with procedural simplex noise —
    //               no three.js, no external textures), reacting to an agent `state`
    //               (idle / listening / talking / thinking). Falls back to a 2D-canvas
    //               sphere where WebGL is unavailable.
    //   gradient  — a glowing "eclipse" sphere: a dark core ringed by a luminous
    //               gradient rim that slowly breathes, with a soft outer halo. Pure
    //               CSS radial/conic gradients (no canvas) reading token-derived
    //               CSS vars (--orb-c1 / --orb-c2) the script sets at runtime.
    //   particle  — a rotating sphere of many small glowing dots (a fibonacci-sphere
    //               point cloud) projected to 2D and spun on the Y axis with a subtle
    //               flowing displacement; front-hemisphere dots are brighter.
    //   geometric — a faceted latitude/longitude wireframe sphere rotating in 3D,
    //               with depth-based line opacity.
    //
    // The `state` prop drives the liquid shader; for the decorative variants it
    // subtly modulates motion. DPR-aware, reduced-motion safe (a single calm static
    // frame, no rAF loop), and tears down cleanly. Self-registers on alpine:init.
    $variants = ['aurora', 'liquid', 'gradient', 'particle', 'geometric'];
    $states = ['idle', 'listening', 'talking', 'thinking'];
    $config = [
        'variant' => in_array($variant, $variants, true) ? $variant : 'aurora',
        'state' => in_array($state, $states, true) ? $state : 'idle',
    ];
    $ariaLabel = $label ?? __('Voice orb');
@endphp

<div
    data-slot="orb"
    data-variant="{{ $config['variant'] }}"
    x-data="uiOrb(@js($config))"
    x-ref="root"
    :data-state="state"
    role="img"
    aria-label="{{ $ariaLabel }}"
    {{ $attributes->merge(['class' => 'relative block aspect-square w-40']) }}
>
    {{-- liquid + particle + geometric render onto the canvas; gradient ignores it. --}}
    <canvas x-ref="canvas" class="block size-full" :class="variant === 'gradient' && 'sr-only'" aria-hidden="true"></canvas>

    {{-- CSS-only "eclipse" sphere for the gradient variant. The colour stops read
         CSS vars the script resolves from --primary / --accent at runtime, so no
         raw colour (or inline hsl()) lives in this source. --}}
    <template x-if="variant === 'gradient'">
        <div class="absolute inset-0" aria-hidden="true">
            <span class="orb-eclipse-halo absolute inset-0"></span>
            <span class="orb-eclipse-core absolute inset-0"></span>
            <span class="orb-eclipse-rim absolute inset-0"></span>
        </div>
    </template>

    <style>
        [data-slot="orb"][data-variant="gradient"] .orb-eclipse-halo {
            border-radius: 9999px;
            background: radial-gradient(circle at 50% 50%, var(--orb-c2) 0%, transparent 62%);
            opacity: 0.45;
            filter: blur(14px);
            transform: scale(1.18);
            animation: orb-eclipse-breathe 6s ease-in-out infinite;
        }
        [data-slot="orb"][data-variant="gradient"] .orb-eclipse-rim {
            border-radius: 9999px;
            background:
                conic-gradient(from 210deg, var(--orb-c1), var(--orb-c2), var(--orb-c1));
            -webkit-mask: radial-gradient(circle at 50% 50%, transparent 58%, black 62%, black 78%, transparent 82%);
            mask: radial-gradient(circle at 50% 50%, transparent 58%, black 62%, black 78%, transparent 82%);
            opacity: 0.9;
            filter: blur(2px);
            animation: orb-eclipse-spin 14s linear infinite;
        }
        [data-slot="orb"][data-variant="gradient"] .orb-eclipse-core {
            border-radius: 9999px;
            background:
                radial-gradient(circle at 38% 34%, var(--orb-c1) 0%, transparent 46%),
                radial-gradient(circle at 50% 50%, transparent 40%, var(--orb-c1) 60%, var(--orb-c2) 100%);
            -webkit-mask: radial-gradient(circle at 50% 50%, black 60%, transparent 64%);
            mask: radial-gradient(circle at 50% 50%, black 60%, transparent 64%);
            opacity: 0.92;
            animation: orb-eclipse-breathe 6s ease-in-out infinite;
        }
        @keyframes orb-eclipse-breathe {
            0%, 100% { transform: scale(1); }
            50% { transform: scale(1.04); }
        }
        @keyframes orb-eclipse-spin {
            to { transform: rotate(360deg); }
        }
        @media (prefers-reduced-motion: reduce) {
            [data-slot="orb"][data-variant="gradient"] .orb-eclipse-halo,
            [data-slot="orb"][data-variant="gradient"] .orb-eclipse-core,
            [data-slot="orb"][data-variant="gradient"] .orb-eclipse-rim {
                animation: none;
            }
        }
    </style>
</div>
resources/js/ui/orb.js JS
/**
 * Orb behaviour — a decorative orb with five looks (`variant`):
 *
 *   aurora    (default) — the WebGL sphere below in its pale, glassy mode: two
 *                         soft colour clouds drift over a near-white body with a
 *                         feathered edge and a slow breath (the ChatGPT voice look).
 *   liquid              — the same shader as a saturated, lit liquid sphere.
 *   gradient            — a CSS-only "eclipse" sphere; this script only resolves
 *                         the token colours into the --orb-c1 / --orb-c2 vars the
 *                         Blade gradients read, then returns (no canvas, no loop).
 *   particle            — a fibonacci-sphere point cloud projected to 2D, spun on
 *                         the Y axis with a subtle flowing wobble; front dots are
 *                         brighter/bigger (depth-based) and additively blended.
 *   geometric           — a latitude/longitude wireframe sphere rotating in 3D
 *                         with depth-based line opacity (back of the cage fades).
 *
 * The particle/geometric variants are DPR-aware, reduced-motion safe (one static
 * frame, no rAF) and clean up in destroy() alongside the liquid path. `state`
 * subtly modulates their spin energy; it fully drives the liquid shader.
 *
 * --- liquid variant ---------------------------------------------------------
 * A reinterpretation of the ElevenLabs WebGL orb in hand-written WebGL1 / GLSL
 * ES 1.00 — no Three.js, no npm, no external textures. A full-screen quad is
 * shaded into a glowing liquid SPHERE: a fake sphere normal gives 3D shading,
 * animated 3D simplex-noise fbm flows two colours across the surface, a fresnel
 * rim rounds the silhouette, a specular highlight adds gloss and a soft halo
 * bleeds past the edge. An `amplitude` uniform (eased per state) makes it
 * breathe — idle/listening/talking/thinking modulate energy and flow speed.
 *
 * Colour: hue + saturation come from the `--primary` / `--accent` design tokens
 * (or an explicit `colors` override of "H S% L%" triples), but the LIGHTNESS is
 * synthesised so the orb stays a vivid, lit sphere even when a token is dark.
 * Colours are converted to RGB in JS and uploaded as uniforms — nothing
 * raw-coloured lives in the source. Re-read on theme (class) flips.
 *
 * DPR-aware (capped at 2) with a ResizeObserver; WebGL context loss is handled
 * (rAF stops on lost, program rebuilds + loop restarts on restore). Reduced
 * motion paints one static frame and never starts the loop. If WebGL is
 * unavailable it falls back to a simple 2D-canvas radial sphere. destroy()
 * cancels the rAF, disconnects observers and releases GL resources.
 *
 * Self-registers on `alpine:init`. Plain JS — no Blade syntax.
 */
document.addEventListener('alpine:init', () => {
    const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
    const clamp01 = (v) => clamp(v, 0, 1);

    const parseChannels = (value) => {
        const m = String(value).match(/(-?[\d.]+)\s+(-?[\d.]+)%\s+(-?[\d.]+)%/);
        if (!m) return null;
        return { h: parseFloat(m[1]), s: parseFloat(m[2]), l: parseFloat(m[3]) };
    };

    // HSL (h 0..360, s/l 0..100) → [r, g, b] in 0..1.
    const hslToRgb = (h, s, l) => {
        h = (((h % 360) + 360) % 360) / 360;
        s = clamp(s, 0, 100) / 100;
        l = clamp(l, 0, 100) / 100;
        if (s === 0) return [l, l, l];
        const hue = (p, q, t) => {
            if (t < 0) t += 1;
            if (t > 1) t -= 1;
            if (t < 1 / 6) return p + (q - p) * 6 * t;
            if (t < 1 / 2) return q;
            if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
            return p;
        };
        const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
        const p = 2 * l - q;
        return [hue(p, q, h + 1 / 3), hue(p, q, h), hue(p, q, h - 1 / 3)];
    };

    const rgbToHsl = ({ r, g, b }) => {
        r /= 255;
        g /= 255;
        b /= 255;
        const max = Math.max(r, g, b);
        const min = Math.min(r, g, b);
        const delta = max - min;
        let h = 0;
        if (delta !== 0) {
            if (max === r) h = 60 * (((g - b) / delta) % 6);
            else if (max === g) h = 60 * ((b - r) / delta + 2);
            else h = 60 * ((r - g) / delta + 4);
        }
        if (h < 0) h += 360;
        const l = (max + min) / 2;
        const s = delta === 0 ? 0 : delta / (1 - Math.abs(2 * l - 1));

        return { h, s: s * 100, l: l * 100 };
    };

    const VERT = 'attribute vec2 a_position;void main(){gl_Position=vec4(a_position,0.0,1.0);}';

    // 3D simplex noise (Ashima / Stefan Gustavson, public domain) — procedural,
    // so the surface flow needs no external noise texture.
    const NOISE = `
        vec3 mod289(vec3 x){return x-floor(x*(1.0/289.0))*289.0;}
        vec4 mod289(vec4 x){return x-floor(x*(1.0/289.0))*289.0;}
        vec4 permute(vec4 x){return mod289(((x*34.0)+1.0)*x);}
        vec4 taylorInvSqrt(vec4 r){return 1.79284291400159-0.85373472095314*r;}
        float snoise(vec3 v){
            const vec2 C=vec2(1.0/6.0,1.0/3.0);
            const vec4 D=vec4(0.0,0.5,1.0,2.0);
            vec3 i=floor(v+dot(v,C.yyy));
            vec3 x0=v-i+dot(i,C.xxx);
            vec3 g=step(x0.yzx,x0.xyz);
            vec3 l=1.0-g;
            vec3 i1=min(g.xyz,l.zxy);
            vec3 i2=max(g.xyz,l.zxy);
            vec3 x1=x0-i1+C.xxx;
            vec3 x2=x0-i2+C.yyy;
            vec3 x3=x0-D.yyy;
            i=mod289(i);
            vec4 p=permute(permute(permute(
                i.z+vec4(0.0,i1.z,i2.z,1.0))
                +i.y+vec4(0.0,i1.y,i2.y,1.0))
                +i.x+vec4(0.0,i1.x,i2.x,1.0));
            float n_=0.142857142857;
            vec3 ns=n_*D.wyz-D.xzx;
            vec4 j=p-49.0*floor(p*ns.z*ns.z);
            vec4 x_=floor(j*ns.z);
            vec4 y_=floor(j-7.0*x_);
            vec4 x=x_*ns.x+ns.yyyy;
            vec4 y=y_*ns.x+ns.yyyy;
            vec4 h=1.0-abs(x)-abs(y);
            vec4 b0=vec4(x.xy,y.xy);
            vec4 b1=vec4(x.zw,y.zw);
            vec4 s0=floor(b0)*2.0+1.0;
            vec4 s1=floor(b1)*2.0+1.0;
            vec4 sh=-step(h,vec4(0.0));
            vec4 a0=b0.xzyw+s0.xzyw*sh.xxyy;
            vec4 a1=b1.xzyw+s1.xzyw*sh.zzww;
            vec3 p0=vec3(a0.xy,h.x);
            vec3 p1=vec3(a0.zw,h.y);
            vec3 p2=vec3(a1.xy,h.z);
            vec3 p3=vec3(a1.zw,h.w);
            vec4 norm=taylorInvSqrt(vec4(dot(p0,p0),dot(p1,p1),dot(p2,p2),dot(p3,p3)));
            p0*=norm.x;p1*=norm.y;p2*=norm.z;p3*=norm.w;
            vec4 m=max(0.6-vec4(dot(x0,x0),dot(x1,x1),dot(x2,x2),dot(x3,x3)),0.0);
            m=m*m;
            return 42.0*dot(m*m,vec4(dot(p0,x0),dot(p1,x1),dot(p2,x2),dot(p3,x3)));
        }`;

    const FRAG = `precision highp float;
        uniform vec2 u_resolution;
        uniform float u_time;
        uniform float u_amp;
        uniform vec3 u_color1;
        uniform vec3 u_color2;
        uniform float u_seed;
        uniform float u_mode;
        ${NOISE}
        void main(){
            vec2 res=u_resolution;
            float mn=min(res.x,res.y);
            vec2 p=(gl_FragCoord.xy-0.5*res)/mn;
            float r=length(p);
            float R=0.36;
            float aa=2.0/mn;
            float t=u_time*0.12+u_seed;
            float amp=u_amp;

            // The silhouette breathes: a slow low-frequency ripple around the
            // rim, scaled by the state's energy, so a talking orb visibly moves
            // instead of only shifting colour.
            float ang=atan(p.y,p.x);
            float wob=snoise(vec3(cos(ang)*1.6,sin(ang)*1.6,t*1.8))*0.5+snoise(vec3(cos(ang)*3.2+9.0,sin(ang)*3.2,t*2.6))*0.25;
            float Re=R*(1.0+wob*(0.012+amp*0.05));
            float mask=1.0-smoothstep(Re-aa,Re+aa,r);

            vec2 uv=p/Re;                                 // -1..1 across the disk
            float dd=clamp(dot(uv,uv),0.0,1.0);
            float z=sqrt(1.0-dd);                         // sphere height (rim 0 -> centre 1)

            // Three octaves of domain-warped fbm read on the sphere surface
            // (uv, z), so the field wraps the volume instead of sliding as a
            // flat sheet; the warp gives it the smoky, folded look.
            vec3 sp=vec3(uv,z);
            vec3 flow=vec3(t*0.5,-t*0.35,t*0.3);
            float a=snoise(sp*1.3+flow);
            float b=snoise(sp*2.6+vec3(a*0.6)+vec3(7.0,3.0,1.0)-flow*0.7);
            float c=snoise(sp*5.2+vec3(b*0.5)+vec3(2.0,11.0,5.0)+flow*1.3);
            float f=0.55*a+0.32*b+0.13*c;                 // ~ -1..1, smooth
            float m=clamp(0.5+0.5*f*(0.55+amp*0.45),0.0,1.0);
            m=smoothstep(0.12,0.88,m);

            vec3 col=mix(u_color1,u_color2,m);

            // Volumetric shading: a lit upper-left, a soft terminator towards
            // the lower-right rim, and a lifted core so the sphere reads as a
            // glowing body rather than a flat disc.
            vec3 n=normalize(vec3(uv,z+0.15));
            vec3 lightDir=normalize(vec3(-0.45,0.55,0.75));
            float diff=clamp(dot(n,lightDir),0.0,1.0);
            col*=0.62+0.48*diff;
            col+=u_color2*pow(z,2.5)*(0.16+amp*0.22);    // luminous core
            float fres=pow(1.0-z,2.0);
            col+=mix(u_color1,u_color2,0.5)*fres*0.30;   // backlit rim
            col=mix(col,col*0.72,pow(1.0-z,6.0));          // a thin dark edge grounds the sphere

            // Broad soft highlight plus a small hot spot near the light.
            vec3 hv=normalize(lightDir+vec3(0.0,0.0,1.0));
            float sh=clamp(dot(n,hv),0.0,1.0);
            col+=pow(sh,8.0)*0.14+pow(sh,48.0)*0.28;

            // Aurora mode (the ChatGPT voice look): a pale, glassy sphere where
            // two big soft colour clouds drift over a near-white body — no
            // terminator, no hot spot, a wide feathered edge and a slow pulse.
            if(u_mode>0.5){
                vec3 white=vec3(0.985,0.99,1.0);
                float g1=smoothstep(-0.35,0.75,snoise(sp*0.9+flow*0.6));
                float g2=smoothstep(-0.3,0.8,snoise(sp*1.4-flow*0.5+vec3(4.0,9.0,2.0)));
                vec3 tint=mix(u_color1,u_color2,g2);
                float wash=0.28+amp*0.30;
                col=mix(white,tint,g1*wash+0.06);
                col=mix(col,white,pow(z,2.2)*0.35);            // milky core
                col+=tint*pow(fres,1.6)*0.28;                  // coloured glass rim
                col=mix(col,vec3(1.0),pow(sh,20.0)*0.18);      // one soft sheen
                float breathe=0.985+0.015*sin(u_time*1.6+u_seed);
                mask=1.0-smoothstep(Re*breathe-aa*6.0,Re*breathe+aa,r);
            }

            // Ambient bloom: light bleeds past the silhouette, strongest when
            // the orb is energetic. Alpha-only, so it sits on any surface.
            float halo=smoothstep(Re*(u_mode>0.5?1.9:1.55),Re,r)*(1.0-mask);
            float haloA=halo*halo*(u_mode>0.5?0.16+amp*0.22:0.22+amp*0.30);
            vec3 haloC=mix(u_color1,u_color2,0.6)*(u_mode>0.5?1.0:1.05);

            col*=0.97+amp*0.10;

            // Straight (non-premultiplied) alpha: the halo pixels carry the halo
            // colour at the halo's alpha, not a darkened blend.
            vec3 outC=mix(haloC,col,mask);
            float outA=max(mask,haloA);
            gl_FragColor=vec4(outC,outA);
        }`;

    window.Alpine.data('uiOrb', (config = {}) => ({
        variant: config.variant || 'aurora',
        state: config.state || 'idle',
        colors: Array.isArray(config.colors) ? config.colors : null,

        _canvas: null,
        _gl: null,
        _program: null,
        _buffer: null,
        _loc: null,
        _dpr: 1,
        _raf: 0,
        _start: 0,
        _ro: null,
        _mo: null,
        _reduced: false,
        _amp: 0.3,
        _seed: 0,
        _c1: [0.5, 0.6, 0.95],
        _c2: [0.65, 0.8, 1.0],
        _ctx2d: null, // 2D / decorative canvas context
        _onLost: null,
        _onRestored: null,
        _points: null, // fibonacci-sphere point cloud (particle variant)
        _viewObserver: null,
        _offscreen: false,
        _resume: null, // restarts whichever loop (liquid/2d/decorative) is active

        init() {
            this._canvas = this.$refs.canvas;
            this._reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
            // Per-instance phase offset so multiple orbs don't move in lockstep.
            this._seed = (Date.now() % 1000) / 1000 * 10;

            // Resolve token colours up front; re-read on theme (class) flips.
            this.resolveColors();
            this._mo = new MutationObserver(() => this.resolveColors());
            this._mo.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });

            // Pause the rAF loop while fully scrolled out of view (same-tab;
            // backgrounded tabs are already throttled by the browser). The active
            // boot path sets `_resume` to restart *its* loop only — we never call
            // boot() again, which would rebuild the WebGL program/buffers. The CSS
            // "gradient" variant and reduced-motion paths never set `_raf`, so the
            // `&& this._raf` guard leaves them inert.
            if (typeof IntersectionObserver !== 'undefined') {
                this._viewObserver = new IntersectionObserver((entries) => {
                    const visible = entries.some((e) => e.isIntersecting);
                    if (!visible && this._raf) {
                        cancelAnimationFrame(this._raf);
                        this._raf = 0;
                        this._offscreen = true;
                    } else if (visible && this._offscreen && this._resume) {
                        this._offscreen = false;
                        this._resume();
                    }
                });
                this._viewObserver.observe(this.$refs.root);
            }

            // The CSS "eclipse" variant needs no canvas/JS loop — its motion is
            // pure CSS keyframes. resolveColors() has set the colour vars; done.
            if (this.variant === 'gradient') return;
            if (!this._canvas) return;

            if (this.variant === 'particle' || this.variant === 'geometric') {
                this.bootDecorative();
                return;
            }

            // --- liquid (default): WebGL sphere -------------------------------
            this._onLost = (e) => {
                e.preventDefault();
                if (this._raf) {
                    cancelAnimationFrame(this._raf);
                    this._raf = 0;
                }
            };
            this._onRestored = () => this.boot();
            this._canvas.addEventListener('webglcontextlost', this._onLost, false);
            this._canvas.addEventListener('webglcontextrestored', this._onRestored, false);

            this.boot();
        },

        resolveColors() {
            const resolved = this.colors === null
                ? [window.UI?.resolveColorRgb?.('primary', this.$refs.root), window.UI?.resolveColorRgb?.('accent', this.$refs.root)]
                : this.colors;
            const p = this.colors === null ? (resolved[0] ? rgbToHsl(resolved[0]) : null) : parseChannels(resolved[0]);
            let a = this.colors === null ? (resolved[1] ? rgbToHsl(resolved[1]) : null) : parseChannels(resolved[1]);
            const primary = p || { h: 222, s: 70, l: 50 };
            if (!a || a.s <= 12) a = { h: primary.h + 28, s: clamp(primary.s, 55, 90), l: 60 };
            const sat = clamp(primary.s, 58, 92);
            // Synthesised lightness keeps the orb vivid regardless of token L.
            this._c1 = hslToRgb(primary.h, sat, 50);
            this._c2 = hslToRgb(a.h, clamp(a.s, 58, 95), 72);

            // The gradient (CSS eclipse) variant paints from two CSS vars; set
            // them here from the resolved RGB so nothing raw-coloured ships in
            // the Blade source. (rgb() string — never an inline hsl().)
            const root = this.$refs.root;
            if (root) {
                root.style.setProperty('--orb-c1', this.rgb(this._c1, 1));
                root.style.setProperty('--orb-c2', this.rgb(this._c2, 1));
            }
        },

        boot() {
            const gl =
                this._canvas.getContext('webgl', { premultipliedAlpha: false, alpha: true, antialias: true }) ||
                this._canvas.getContext('experimental-webgl', { premultipliedAlpha: false, alpha: true });
            if (!gl) {
                this.boot2d();
                return;
            }
            this._gl = gl;

            this._program = this.buildProgram(gl);
            if (!this._program) {
                this._gl = null;
                this.boot2d();
                return;
            }

            this._buffer = gl.createBuffer();
            gl.bindBuffer(gl.ARRAY_BUFFER, this._buffer);
            gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1]), gl.STATIC_DRAW);
            const aPos = gl.getAttribLocation(this._program, 'a_position');
            gl.enableVertexAttribArray(aPos);
            gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, 0, 0);

            this._loc = {
                resolution: gl.getUniformLocation(this._program, 'u_resolution'),
                time: gl.getUniformLocation(this._program, 'u_time'),
                amp: gl.getUniformLocation(this._program, 'u_amp'),
                mode: gl.getUniformLocation(this._program, 'u_mode'),
                color1: gl.getUniformLocation(this._program, 'u_color1'),
                color2: gl.getUniformLocation(this._program, 'u_color2'),
                seed: gl.getUniformLocation(this._program, 'u_seed'),
            };

            gl.useProgram(this._program);
            gl.clearColor(0, 0, 0, 0);

            this.resize();
            this._ro = new ResizeObserver(() => this.resize());
            this._ro.observe(this.$refs.root);

            if (this._reduced) {
                this.render(0);
                return;
            }
            this._start = performance.now();
            this._resume = () => { this._raf = requestAnimationFrame((t) => this.loop(t)); };
            this._raf = requestAnimationFrame((t) => this.loop(t));
        },

        buildProgram(gl) {
            const vs = this.compile(gl, gl.VERTEX_SHADER, VERT);
            const fs = this.compile(gl, gl.FRAGMENT_SHADER, FRAG);
            if (!vs || !fs) return null;
            const program = gl.createProgram();
            gl.attachShader(program, vs);
            gl.attachShader(program, fs);
            gl.linkProgram(program);
            gl.deleteShader(vs);
            gl.deleteShader(fs);
            if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
                gl.deleteProgram(program);
                return null;
            }
            return program;
        },

        compile(gl, type, source) {
            const shader = gl.createShader(type);
            gl.shaderSource(shader, source);
            gl.compileShader(shader);
            if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
                gl.deleteShader(shader);
                return null;
            }
            return shader;
        },

        targetAmp(time) {
            switch (this.state) {
                case 'talking':
                    return clamp01(0.7 + Math.sin(time * 9) * 0.25 + Math.sin(time * 4.3) * 0.1);
                case 'listening':
                    return clamp01(0.5 + Math.sin(time * 5.5) * 0.2);
                case 'thinking':
                    return clamp01(0.42 + Math.sin(time * 2.1) * 0.08);
                case 'idle':
                default:
                    return clamp01(0.32 + Math.sin(time * 1.2) * 0.06);
            }
        },

        resize() {
            const root = this.$refs.root;
            this._dpr = Math.min(window.devicePixelRatio || 1, 2);
            const w = Math.max(1, Math.round((root.clientWidth || 1) * this._dpr));
            const h = Math.max(1, Math.round((root.clientHeight || 1) * this._dpr));
            if (this._canvas.width !== w || this._canvas.height !== h) {
                this._canvas.width = w;
                this._canvas.height = h;
            }
            if (this._gl) {
                this._gl.viewport(0, 0, w, h);
                if (this._reduced) this.render(0);
            } else if (this._ctx2d) {
                // Repaint the current static frame on resize. The decorative
                // (particle/geometric) variants share this 2D context but draw
                // their own geometry; the liquid 2D fallback uses render2d().
                if (this.variant === 'particle' || this.variant === 'geometric') {
                    if (this._reduced) this.renderDecorative(0);
                } else {
                    this.render2d();
                }
            }
        },

        loop(now) {
            const time = (now - this._start) / 1000;
            this._amp += (this.targetAmp(time) - this._amp) * 0.08;
            this.render(time);
            this._raf = requestAnimationFrame((t) => this.loop(t));
        },

        render(time) {
            const gl = this._gl;
            if (!gl || !this._program || gl.isContextLost()) return;
            gl.useProgram(this._program);
            gl.uniform2f(this._loc.resolution, this._canvas.width, this._canvas.height);
            gl.uniform1f(this._loc.time, time);
            gl.uniform1f(this._loc.amp, this._amp);
            gl.uniform1f(this._loc.mode, this.variant === 'aurora' ? 1 : 0);
            gl.uniform1f(this._loc.seed, this._seed);
            gl.uniform3f(this._loc.color1, this._c1[0], this._c1[1], this._c1[2]);
            gl.uniform3f(this._loc.color2, this._c2[0], this._c2[1], this._c2[2]);
            gl.clear(gl.COLOR_BUFFER_BIT);
            gl.drawArrays(gl.TRIANGLES, 0, 6);
        },

        // ---- 2D fallback (no WebGL): a simple shaded radial sphere ----------
        boot2d() {
            this._ctx2d = this._canvas.getContext('2d');
            if (!this._ctx2d) return;
            this.resize();
            this._ro = new ResizeObserver(() => this.resize());
            this._ro.observe(this.$refs.root);
            if (this._reduced) {
                this.render2d();
                return;
            }
            this._start = performance.now();
            this._resume = () => { this._raf = requestAnimationFrame((t) => this.loop2d(t)); };
            this._raf = requestAnimationFrame((t) => this.loop2d(t));
        },

        loop2d(now) {
            const time = (now - this._start) / 1000;
            this._amp += (this.targetAmp(time) - this._amp) * 0.08;
            this.render2d();
            this._raf = requestAnimationFrame((t) => this.loop2d(t));
        },

        rgb(c, a) {
            return `rgba(${Math.round(c[0] * 255)},${Math.round(c[1] * 255)},${Math.round(c[2] * 255)},${a})`;
        },

        render2d() {
            const ctx = this._ctx2d;
            if (!ctx) return;
            const w = this._canvas.width;
            const h = this._canvas.height;
            const cx = w / 2;
            const cy = h / 2;
            const rad = Math.min(w, h) * 0.42 * (1 + this._amp * 0.05);
            ctx.clearRect(0, 0, w, h);
            const g = ctx.createRadialGradient(cx - rad * 0.3, cy - rad * 0.35, rad * 0.05, cx, cy, rad);
            g.addColorStop(0, this.rgb(this._c2, 1));
            g.addColorStop(0.6, this.rgb(this._c1, 1));
            g.addColorStop(1, this.rgb(this._c1, 0.85));
            ctx.fillStyle = g;
            ctx.beginPath();
            ctx.arc(cx, cy, rad, 0, Math.PI * 2);
            ctx.fill();
        },

        // ---- Decorative 2D variants (particle / geometric) ------------------
        // Both project a unit sphere onto the canvas, rotate it on the Y axis
        // and paint token-coloured geometry with depth (front-hemisphere
        // brighter). DPR-aware via the shared resize(); reduced-motion paints a
        // single static frame and never starts the loop.
        bootDecorative() {
            this._ctx2d = this._canvas.getContext('2d');
            if (!this._ctx2d) return;
            if (this.variant === 'particle') this.buildPoints();

            this.resize();
            this._ro = new ResizeObserver(() => this.resize());
            this._ro.observe(this.$refs.root);

            if (this._reduced) {
                this.renderDecorative(0);
                return;
            }
            this._start = performance.now();
            this._resume = () => { this._raf = requestAnimationFrame((t) => this.loopDecorative(t)); };
            this._raf = requestAnimationFrame((t) => this.loopDecorative(t));
        },

        // Even point distribution on a unit sphere (fibonacci spiral).
        buildPoints() {
            const n = 360;
            const pts = new Array(n);
            const golden = Math.PI * (3 - Math.sqrt(5));
            for (let i = 0; i < n; i++) {
                const y = 1 - (i / (n - 1)) * 2; // 1..-1
                const r = Math.sqrt(Math.max(0, 1 - y * y));
                const theta = golden * i;
                pts[i] = [Math.cos(theta) * r, y, Math.sin(theta) * r];
            }
            this._points = pts;
        },

        loopDecorative(now) {
            const time = (now - this._start) / 1000;
            // `state` subtly modulates spin energy; eased so it never jolts.
            this._amp += (this.targetAmp(time) - this._amp) * 0.06;
            this.renderDecorative(time);
            this._raf = requestAnimationFrame((t) => this.loopDecorative(t));
        },

        renderDecorative(time) {
            if (this.variant === 'geometric') this.renderGeometric(time);
            else this.renderParticle(time);
        },

        renderParticle(time) {
            const ctx = this._ctx2d;
            if (!ctx) return;
            const w = this._canvas.width;
            const h = this._canvas.height;
            const cx = w / 2;
            const cy = h / 2;
            const rad = Math.min(w, h) * 0.42;
            const spin = time * (0.35 + this._amp * 0.5);
            const cosA = Math.cos(spin);
            const sinA = Math.sin(spin);
            const c1 = this._c1;
            const c2 = this._c2;
            const dpr = this._dpr || 1;

            ctx.clearRect(0, 0, w, h);
            ctx.globalCompositeOperation = 'lighter';
            const pts = this._points || [];
            for (let i = 0; i < pts.length; i++) {
                const p = pts[i];
                // Subtle flowing displacement so the surface shimmers.
                const wob = 1 + 0.05 * Math.sin(time * 1.6 + i * 0.7);
                let x = p[0] * wob;
                const y = p[1] * wob;
                let z = p[2] * wob;
                // Rotate about Y.
                const rx = x * cosA + z * sinA;
                z = -x * sinA + z * cosA;
                x = rx;

                const depth = (z + 1) / 2; // 0 back .. 1 front
                const sx = cx + x * rad;
                const sy = cy + y * rad;
                // Front dots brighter/bigger; mix the two token colours by height.
                const mix = (y + 1) / 2;
                const col = [
                    c1[0] + (c2[0] - c1[0]) * mix,
                    c1[1] + (c2[1] - c1[1]) * mix,
                    c1[2] + (c2[2] - c1[2]) * mix,
                ];
                const alpha = 0.12 + depth * depth * 0.7;
                const size = (0.7 + depth * 1.8) * dpr;
                ctx.fillStyle = this.rgb(col, alpha);
                ctx.beginPath();
                ctx.arc(sx, sy, size, 0, Math.PI * 2);
                ctx.fill();
            }
            ctx.globalCompositeOperation = 'source-over';
        },

        renderGeometric(time) {
            const ctx = this._ctx2d;
            if (!ctx) return;
            const w = this._canvas.width;
            const h = this._canvas.height;
            const cx = w / 2;
            const cy = h / 2;
            const rad = Math.min(w, h) * 0.42;
            const dpr = this._dpr || 1;
            const spin = time * (0.3 + this._amp * 0.4);
            const cosA = Math.cos(spin);
            const sinA = Math.sin(spin);
            // Gentle wobble on X so the poles aren't fixed flat.
            const tilt = 0.32 + 0.05 * Math.sin(time * 0.6);
            const cosT = Math.cos(tilt);
            const sinT = Math.sin(tilt);

            const project = (lat, lon) => {
                const cl = Math.cos(lat);
                let x = cl * Math.cos(lon);
                let y = Math.sin(lat);
                let z = cl * Math.sin(lon);
                // Rotate Y (spin)
                let rx = x * cosA + z * sinA;
                z = -x * sinA + z * cosA;
                x = rx;
                // Rotate X (tilt)
                const ry = y * cosT - z * sinT;
                z = y * sinT + z * cosT;
                y = ry;
                return { x: cx + x * rad, y: cy + y * rad, z };
            };

            ctx.clearRect(0, 0, w, h);
            ctx.lineCap = 'round';
            const c2 = this._c2;
            const TWO_PI = Math.PI * 2;
            const LAT = 7; // parallels
            const LON = 12; // meridians
            const STEP = 18; // segments per arc

            // Parallels (latitude rings).
            for (let i = 1; i < LAT; i++) {
                const lat = -Math.PI / 2 + (Math.PI * i) / LAT;
                this.strokeArc(ctx, c2, rad, dpr, (s) => project(lat, s * TWO_PI), STEP);
            }
            // Meridians (longitude half-rings, pole to pole).
            for (let j = 0; j < LON; j++) {
                const lon = (TWO_PI * j) / LON;
                this.strokeArc(ctx, c2, rad, dpr, (s) => project(-Math.PI / 2 + s * Math.PI, lon), STEP);
            }
        },

        // Stroke a parametric arc as short segments, each shaded by its mean
        // depth so the back of the wireframe fades (depth-based opacity).
        strokeArc(ctx, col, rad, dpr, fn, steps) {
            let prev = fn(0);
            for (let k = 1; k <= steps; k++) {
                const cur = fn(k / steps);
                const depth = ((prev.z + cur.z) / 2 + 1) / 2; // 0 back .. 1 front
                ctx.strokeStyle = this.rgb(col, 0.08 + depth * depth * 0.62);
                ctx.lineWidth = (0.5 + depth * 1.1) * dpr;
                ctx.beginPath();
                ctx.moveTo(prev.x, prev.y);
                ctx.lineTo(cur.x, cur.y);
                ctx.stroke();
                prev = cur;
            }
        },

        destroy() {
            if (this._viewObserver) { this._viewObserver.disconnect(); this._viewObserver = null; }
            if (this._raf) cancelAnimationFrame(this._raf);
            this._raf = 0;
            if (this._ro) this._ro.disconnect();
            this._ro = null;
            if (this._mo) this._mo.disconnect();
            this._mo = null;
            if (this._canvas) {
                if (this._onLost) this._canvas.removeEventListener('webglcontextlost', this._onLost, false);
                if (this._onRestored) this._canvas.removeEventListener('webglcontextrestored', this._onRestored, false);
            }
            const gl = this._gl;
            if (gl) {
                if (this._buffer) gl.deleteBuffer(this._buffer);
                if (this._program) gl.deleteProgram(this._program);
                const lose = gl.getExtension('WEBGL_lose_context');
                if (lose) lose.loseContext();
            }
            this._gl = this._program = this._buffer = this._loc = this._ctx2d = null;
            this._points = null;
        },
    }));
});

Ownership & lifecycle

Owner, release state, review evidence and adoption for this item.
Owner
Platform UI (@JoshJML)
Current version
1.5.2
Status
Stable
License
open
Accessibility reviewed
No review date recorded
Last breaking change
No date recorded
Deprecation
Not deprecated
Contract
v6
Foundation
≥ 1.0.0