AI

AI Orb

A WebGL2 sphere with colour marbling inside it, lit like a real material.

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/b plane.

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

Aberration
Balance
Blob Scale
Chroma
Colors
Contrast
Duration
Flow
Glow
Grain
Inner
Iridescence
Light X
Light Y
Paused
Refraction
Rim
Shading
Shift
Size240
Softness
Specular
Turbulence
Wobble

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
sizenumber | string240Rendered 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.
colorsstring[]['#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.
durationnumber7Seconds for one full, seamless loop.
flownumber0.6How far the flow field carries the field over a loop.
turbulencenumber0.4Strength of the second, nested warp — marbling rather than blobs.
blobScalenumber3Interior cell size. Small is busy; large is one slow swell.
rimnumber1.3Fresnel edge brightness. This is the prop that makes the orb read as glass.
specularnumber0.2Specular highlight strength.
innernumber0.38Internal scatter, offset away from the highlight. Fake subsurface scattering.
refractionnumber0.25Bends the noise lookup by the surface normal.
iridescencenumber0Fresnel-driven hue rotation — thin-film sheen.
aberrationnumber0.8Rim dispersion. Splits the palette lookup per channel toward the edge, the way a lens fringes.
chromanumber1Hue ripple through the palette.
contrastnumber1Lightness contrast around the midpoint.
balancenumber0.5Which colour dominates. 0.5 is even; below favours the first stop.
shiftnumber0.23Rotates the palette.
shadingnumber0.05Blends the palette toward plain diffuse shading.
glownumber0A halo cast outside the silhouette. 0 is none.
grainnumber0.6Film grain. Also hides the banding large soft gradients show.
softnessnumber0.005Edge feather. 0 still resolves to about 1.5 device pixels, so the silhouette never aliases.
lightXnumber-0.6Light direction, x. With lightY it also decides which corner the fallback gradient is lit from.
lightYnumber-1Light direction, y.
wobblenumber0Silhouette deformation, so the orb reads as a drop rather than a ball.
pausedbooleanfalseFreeze on the current frame. Props still apply, and a single frame is drawn after every change — a paused orb is never stale.
classstring-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
uiobject-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 requestAnimationFrame loop per instance, capped at 60fps. Ambient motion gains nothing from a 120 Hz display.
  • The context is requested with powerPreference: 'low-power', and with depth, stencil and antialias all 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 colors is converted, and only once per change.
  • ResizeObserver is used with device-pixel-content-box when available and falls back to plain observe, with DPR clamped to 2.
  • An IntersectionObserver with a 120px rootMargin stops 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.
  • onBeforeUnmount disconnects both observers, removes the matchMedia listener and deletes the program. It deliberately does not call WEBGL_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.
Copyright © 2026