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.
Preview
Idle
Listening
Talking
<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
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:
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.
-
resources/views/components/ui/orb.blade.php -
resources/js/ui/orb.js
- Registry dependencies
- None — installs on its own.
- Packages
-
composer: jml/brok:^0.2npm: 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.
# 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
<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
Props
| 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.
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
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
- Theming hooks
Accessibility
- 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-slotattribute for styling and scripting hooks. - Focus-visible rings use the
ringtoken, 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 underdir="rtl"— flip the preview to RTL to confirm. - Dark mode uses the same semantic tokens under the
darkclass; high contrast follows forced-color system tokens.
Livewire
Add wire:ignore to the component root because its behavior owns rendered DOM.
<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.
@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>
/**
* 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