AI Orb
A configurable WebGL2 orb — a lit sphere with colour marbling inside it. Every pixel derives a normal from its distance to the centre, which is what lets a rim, a highlight and a shaded side exist at all. A radial gradient has no surface, and that is the entire difference between this and a coloured circle.
Two ideas carry the whole thing:
- The loop is seamless by construction. Every point walks a circle in a flow field whose angle advances by exactly one turn per loop, so the displacement returns to where it started. Neighbouring points begin at different angles, and that is what reads as swirling — offset the whole domain by one shared vector instead and you get a pulse.
- Colour is mixed in OkLab. The midpoint between two stops stays clean instead
of going muddy through sRGB, each picked colour reproduces exactly at its own
stop, and the hue ripple is a real rotation in the
a/bplane.
There is no state prop here on purpose. State lives in the character layer —
see AI Orb Face, which composes this material.
Usage
Give it colors — two to four colour stops, mixed in OkLab — and let it run. Any
CSS colour string is accepted, including a Nuxt UI token such as
var(--ui-primary); each stop is resolved to real pixels before it reaches the
shader. Every other prop tunes the material; paused and reducedMotion exist
so you can stop it in a screenshot test or honour the OS preference from your own
code.
<template>
<div class="flex w-full max-w-lg flex-col gap-6">
<div class="flex flex-wrap items-center justify-center gap-6 rounded-2xl border border-default p-8">
<MAiOrb
:colors="['var(--ui-primary)', 'var(--ui-secondary)']"
:glow="glow"
:size="140"
:turbulence="turbulence"
/>
<MAiOrb
:colors="['var(--ui-primary)', 'var(--ui-color-primary-300)', 'var(--ui-secondary)']"
:flow="flow"
:glow="glow"
:iridescence="0.6"
:size="140"
:turbulence="turbulence"
/>
<MAiOrb
:colors="['var(--ui-color-primary-400)', 'var(--ui-color-primary-200)']"
:glow="glow"
:paused="paused"
:size="140"
:wobble="0.6"
/>
</div>
<div class="flex flex-wrap items-center gap-5">
<USwitch
v-model="paused"
class="shrink-0"
label="paused"
/>
<label class="flex flex-1 items-center gap-3 text-xs text-muted">
flow
<USlider
v-model="flow"
class="flex-1"
:max="1.6"
:min="0"
:step="0.05"
/>
</label>
<label class="flex flex-1 items-center gap-3 text-xs text-muted">
turbulence
<USlider
v-model="turbulence"
class="flex-1"
:max="1.2"
:min="0"
:step="0.05"
/>
</label>
<label class="flex flex-1 items-center gap-3 text-xs text-muted">
glow
<USlider
v-model="glow"
class="flex-1"
:max="1"
:min="0"
:step="0.05"
/>
</label>
</div>
</div>
</template>
<script setup lang="ts">
const paused = ref(false)
const flow = ref(0.6)
const turbulence = ref(0.4)
const glow = ref(0)
</script>
Customize
Customize
Props
Every field below is optional; omit one and the default in the right-hand column applies. They are resolved once per change into a plain config object and pushed as shader uniforms — changing one never rebuilds the WebGL context.
| Prop | Type | Default | Description |
|---|---|---|---|
size | number | string | 240 | Rendered size. A number is pixels; any CSS length string ('100%', '4rem')
passes through unchanged. The backing store follows the element's actual
device-pixel box, capped at 2× DPR. |
colors | string[] | ['#f25aed', '#ffffff'] | Two to four stops. Any CSS colour string works — hex, oklch(), or a Nuxt UI
token such as var(--ui-primary) — and each is resolved to real pixels before
it reaches the shader, converted once per change and never per frame. Two stops
read as a material; four read as iridescent. The non-WebGL fallback uses the
same stops. |
duration | number | 7 | Seconds for one full, seamless loop. |
flow | number | 0.6 | How far the flow field carries the field over a loop. |
turbulence | number | 0.4 | Strength of the second, nested warp — marbling rather than blobs. |
blobScale | number | 3 | Interior cell size. Small is busy; large is one slow swell. |
rim | number | 1.3 | Fresnel edge brightness. This is the prop that makes the orb read as glass. |
specular | number | 0.2 | Specular highlight strength. |
inner | number | 0.38 | Internal scatter, offset away from the highlight. Fake subsurface scattering. |
refraction | number | 0.25 | Bends the noise lookup by the surface normal. |
iridescence | number | 0 | Fresnel-driven hue rotation — thin-film sheen. |
aberration | number | 0.8 | Rim dispersion. Splits the palette lookup per channel toward the edge, the way a lens fringes. |
chroma | number | 1 | Hue ripple through the palette. |
contrast | number | 1 | Lightness contrast around the midpoint. |
balance | number | 0.5 | Which colour dominates. 0.5 is even; below favours the first stop. |
shift | number | 0.23 | Rotates the palette. |
shading | number | 0.05 | Blends the palette toward plain diffuse shading. |
glow | number | 0 | A halo cast outside the silhouette. 0 is none. |
grain | number | 0.6 | Film grain. Also hides the banding large soft gradients show. |
softness | number | 0.005 | Edge feather. 0 still resolves to about 1.5 device pixels, so the silhouette
never aliases. |
lightX | number | -0.6 | Light direction, x. With lightY it also decides which corner the fallback
gradient is lit from. |
lightY | number | -1 | Light direction, y. |
wobble | number | 0 | Silhouette deformation, so the orb reads as a drop rather than a ball. |
paused | boolean | false | Freeze on the current frame. Props still apply, and a single frame is drawn after every change — a paused orb is never stale. |
class | string | - | Merged onto the canvas, or onto the fallback <div> when WebGL2 is
unavailable. |
UI
This component's theme exposes the slots below. Override any of them per instance with the ui prop, or app-wide under ui.aiOrb in app.config.ts.
| Prop | Type | Default | Description |
|---|---|---|---|
ui | object | - | Per-slot classes merged over the theme defaults. |
| Slot | Props | Description |
|---|---|---|
root | - | block |
Accessibility
The canvas is aria-hidden="true" and tabindex="-1" — it is decorative by
construction, and an animation nobody asked for should not be an announced
event. If the orb carries meaning, give it meaning in the DOM around it: a
status label beside it, or an aria-label on a wrapping element. For a
character with a name and an accessible description, use
AI Orb Face, which takes an ariaLabel.
Under prefers-reduced-motion: reduce the loop stops and the phase is pinned to
0, so you get one static frame of the orb rather than a moving one. The
matchMedia listener is live, so changing the OS setting mid-session stops the
loop without a remount.
When WebGL2 is unavailable — an older browser, a blocklisted driver, or too many
contexts — the component renders a CSS radial-gradient instead of a canvas.
That fallback is a flat disc, not a lit sphere. It is aria-hidden, lit from the
same corner as the shader so the character does not appear to change which way
it faces, and it is deliberately not an error state: nothing is logged and
nothing is thrown. A machine that cannot render the real thing still gets a
coloured orb rather than a hole in the layout.
Performance
- One
requestAnimationFrameloop per instance, capped at 60fps. Ambient motion gains nothing from a 120 Hz display. - The context is requested with
powerPreference: 'low-power', and withdepth,stencilandantialiasall off — the covering triangle has no geometric edges to sample. - The fullscreen triangle is built from
gl_VertexID, so there is no vertex buffer and no attribute state to manage. - Config changes push uniforms on the live program; the GL context, program and
shader objects survive every prop change. Only
colorsis converted, and only once per change. ResizeObserveris used withdevice-pixel-content-boxwhen available and falls back to plainobserve, with DPR clamped to 2.- An
IntersectionObserverwith a 120pxrootMarginstops the loop entirely when the orb scrolls out of view. This is the common case, and nothing is gained by burning GPU on an orb nobody can see. onBeforeUnmountdisconnects both observers, removes thematchMedialistener and deletes the program. It deliberately does not callWEBGL_lose_context: Vue re-runs mount hooks in development, the second pass gets the same canvas back with a context that is already lost, and the program then fails to link and the component latches into its fallback permanently.