AI Orb Face
A character — an orb with a face, and a face that is almost entirely eyes.
State lives here and the material lives in AI Orb.
That split is the whole reason for the extra component. A state is a
behaviour, and a behaviour wants the whole silhouette, not one material of it.
Driving an orb straight from state would couple six lighting and flow constants
to a prop that also moves the eyes, and the two would have to be retuned
together forever.
Three things carry the read, and all three are here:
- Expression. Nine named faces built from four numbers per eye, so a squint, a tilt, a lift and a width change are the entire vocabulary. There is no mouth on purpose — a drawn mouth ends up carrying most of the expression, at which point the eyes stop mattering.
- Gaze. The eyes follow the pointer on a spring, which is the one bit of
apparent attention a shape can fake convincingly. While
thinkingthey break away instead, which is what makes that state legible without adding a single graphic to it. - Three state moves. A wobble on fault, a hop on success, breathing while listening. The fault wobble plays once, but the spiral eyes stay for as long as the state lasts — recovering to a neutral face would leave a broken assistant looking fine.
Usage
Set state and leave the rest alone: expression, gaze and the state move are all
derived from it. Override the face only when you want to author one yourself
rather than pick from the nine.
<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">
<MAiOrbFace
:state="state"
aria-label="Assistant"
/>
<MAiOrbFace
:state="'streaming'"
:colors="orbFaceColors"
size="96"
/>
</div>
<div class="flex flex-wrap items-center gap-2">
<UButton
v-for="value in states"
:key="value"
:color="state === value ? 'primary' : 'neutral'"
:label="value"
size="sm"
:variant="state === value ? 'solid' : 'outline'"
@click="state = value"
/>
</div>
</div>
</template>
<script setup lang="ts">
type DemoState = 'idle' | 'listening' | 'thinking' | 'streaming' | 'done' | 'error'
const states: DemoState[] = ['idle', 'listening', 'thinking', 'streaming', 'done', 'error']
const state = ref<DemoState>('idle')
const orbFaceColors = {
body: 'secondary',
bodyEdge: 'color-mix(in oklab, var(--ui-secondary), white 78%)',
feature: 'color-mix(in oklab, var(--ui-secondary), black 82%)',
}
</script>
Customize
Customize
Props
| Prop | Type | Default | Description |
|---|---|---|---|
state | 'idle' | 'listening' | 'thinking' | 'streaming' | 'done' | 'error' | 'idle' | The character's state. It selects an expression, a motion profile, a
saturation, and — where it applies — a one-shot move. Defaults per state:
idle → neutral, listening → alert, thinking → doubtful,
streaming → focused, done → content, error → cross. |
size | number | string | 128 | Pixels when a number, any CSS length when a string. |
ariaLabel | string | - | Names the character for assistive technology. Without it the SVG stays
decorative (aria-hidden, no role); with it the SVG becomes
role="img" with an <title>. This is the prop that turns decoration into a
labelled image — and it is not optional when the character's state is the point. |
gaze | boolean | true | Follow the pointer. Turn it off where the face is decorative and the pointer is busy — a page of six faces all tracking one cursor is noise, not life. |
expression | AiOrbFaceExpressionName | AiOrbFaceExpression | - | Set the face directly and ignore state. A name is one of alert, content,
cross, deadpan, doubtful, focused, neutral, surprised, wink. An
object is { left, right }, each an eye described by h (height multiplier,
0 is shut), w (width multiplier), rotate (degrees, positive tilts the
inner corner down and reads as a scowl) and dy (nudge up or down). Describing
the two eyes independently is what buys a wink. |
colors | Partial<{ body, bodyEdge, feature }> | - | Overrides any of the three colours. Each value accepts a
Nuxt UI color token (e.g. primary) or any CSS
color, so the face can follow your theme or be pinned to fixed colours.
Defaults follow the theme: the body is var(--ui-primary), the edge is mixed
toward white and the features toward black, so the marbled interior reads as a
sphere and the eyes stay legible against it. |
amplitude | number | MotionValue<number> | - | A 0–1 speech level. Widens the eyes and stirs the interior while talking. A
MotionValue is preferred: it updates outside the render cycle, so a 60fps
audio signal never triggers a re-render. For a microphone signal see
useAudioAmplitude; for a demo, useSimulatedAmplitude. |
class | string | - | Merged onto the outer element, which also carries relative block. |
UI
This component's theme exposes the slots below. Override any of them per instance with the ui prop, or app-wide under ui.aiOrbFace in app.config.ts.
| Prop | Type | Default | Description |
|---|---|---|---|
ui | object | - | Per-slot classes merged over the theme defaults. |
| Slot | Props | Description |
|---|---|---|
root | - | relative block |
body | - | absolute |
understudy | - | absolute rounded-full |
orb | - | size-full |
face | - | absolute inset-0 block size-full overflow-visible |
Accessibility
- Pass
ariaLabelwhen the character carries meaning. The SVG then renders asrole="img"with an<title>, and the wrapper SVG stops beingaria-hidden. Omit it and the whole component is decorative, which is the right default for a purely ornamental orb. - The accessible name describes what the character is, not what state it is in. State is visual and fast-changing; announcing it on every transition would be noise. If a state change matters to a reader, surface it as text nearby.
- The SVG uses
viewBox="0 0 100 100"andoverflow-visible, so the sprite scales with the box and the spiral eyes are not clipped. The gaze travel is expressed in viewBox units, which means it stays proportional at any size. - Blinks are random per instance and never announced; they are read as life, not as an event.
- Under
prefers-reduced-motion: reducethe springs, blinks, saccades, breathing, wobble, hop and spirals all resolve to their resting state. The face still shows the correct expression, and it is never blank — a static character is a character. - A blinking spiral does not exceed any contrast ratio between
featureandbody, so it is not purely a colour cue; the spiral geometry itself is the signal. - No pointer or keyboard interaction is available on this component. Gaze tracking is one-directional and needs nothing.
Performance
- The eyes are painted imperatively rather than animated as motion values. On an
SVG node, motion-v promotes style keys to attributes, and the gaze rides in as
xandy— the same keys the eye uses for its own geometry, so a spring on the gaze would overwrite the shape it is meant to move. Composing the transform by hand keeps both, and the springs still drive every frame because they notify on change. - Breathing rides the same painter as the one-shot moves rather than an animation
control, because exactly one thing may own
transformon the body element: a bound:animatewould rewritestyle.transformevery tick and silently discard every hand-written frame. amplitudeis normalised once throughuseAmplitudeValue, which reads a getter rather than a plain value — a prop read at setup would freeze at mount and a later change would be silently ignored. A plain number is mirrored on every change; aMotionValueis passed straight through and never lands in a watcher.- Orb energy is quantised to four steps before it crosses into a render. The orb takes plain props, so without quantising a 60fps signal would trigger 60 renders a second instead of a few.
- The
statewatch usesflush: 'post'and is guarded onmounted, so a state set during setup does not fire the wobble or the hop before there is anything to animate. - A radial-gradient circle sits directly under the shader as a painted understudy. On pages running many WebGL surfaces the browser reclaims the oldest contexts, and a reclaimed orb would leave a bare canvas — which turns the character into a pair of floating eyes.
- The states carry their own saturation, applied as a
saturate()filter on the root. A faulted assistant visibly cools down next to the rest of the interface instead of staying cheerful.