AI Orb Face Mouth
A character — an orb with a face, and a face that has a mouth.
The material lives in AI Orb and the state-driven eyes live in AI Orb Face. This component is the third reading of the same character: the eyes stay, and a mouth joins them. That mouth changes the design, because once one is present the eyes can no longer carry the expression alone. So a mood picks one eye treatment and one mouth at the same time, and the two are tuned against each other so neither can win:
| Mood | Eyes | Mouth | Accent |
|---|---|---|---|
idle | open ovals | gentle smile | — |
happy | arches (^ ^) | open smile | — |
excited | taller ovals, lifted | laugh | — |
thinking | narrowed, tilted | flat line | trailing dots |
surprised | wide ovals, raised brows | open o | — |
sleeping | shut lids | small o | rising zs |
sad | lowered, worried brows | frown | — |
confused | one eye squinted | wavy line | ? |
talking | open ovals | moving mouth | — |
Moods are the public unit because they are how a character is described in
conversation — happy, confused, asleep — while state is how a program is
described. AI_ORB_FACE_MOUTH_STATE_MOOD maps one onto the other, so an
assistant still gets a face for free:
| State | Mood |
|---|---|
idle | idle |
listening | excited |
thinking | thinking |
streaming | happy |
done | happy |
error | sad |
streaming also turns talking on, so the mapped mood supplies the eyes and the
speaking mouth supplies the motion.
Talking is the one modifier. The talking prop replaces only the mouth, so the
eyes keep whatever the mood says and happy, thinking and confused can all
speak. The talking row above is the standalone form — idle eyes with the
speaking mouth — for when there is no mood to layer onto. It never applies while
the character is asleep: sleeping wins.
Usage
Set mood and leave the rest alone: eyes, mouth, brows and accent all come from
it. The second face below wears the same mood with talking on, which is the one
modifier this component ships.
<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">
<MAiOrbFaceMouth
:mood="mood"
aria-label="Assistant"
/>
<MAiOrbFaceMouth
:colors="orbFaceColors"
:mood="mood"
:size="96"
talking
aria-label="Assistant, talking"
/>
</div>
<div class="flex flex-wrap items-center gap-2">
<UButton
v-for="value in moods"
:key="value"
:color="mood === value ? 'primary' : 'neutral'"
:label="value"
size="sm"
:variant="mood === value ? 'solid' : 'outline'"
@click="mood = value"
/>
</div>
</div>
</template>
<script setup lang="ts">
type DemoMood = 'idle' | 'happy' | 'excited' | 'thinking' | 'surprised' | 'sleeping' | 'sad' | 'confused' | 'talking'
const moods: DemoMood[] = ['idle', 'happy', 'excited', 'thinking', 'surprised', 'sleeping', 'sad', 'confused', 'talking']
const mood = ref<DemoMood>('happy')
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 |
|---|---|---|---|
mood | 'idle' | 'happy' | 'excited' | 'thinking' | 'surprised' | 'sleeping' | 'sad' | 'confused' | 'talking' | - | Sets the character directly and ignores state. Every mood is a complete
read — eyes, mouth, optional brows and optional accent — so no combination can
land as an accidental expression. |
talking | boolean | - | Swap the mouth for a speaking one without changing the rest of the face, so
happy, thinking and confused can all talk. Defaults to on for the
streaming state, and never applies while the character is asleep. |
state | 'idle' | 'listening' | 'thinking' | 'streaming' | 'done' | 'error' | 'idle' | The assistant state. Used only when mood is absent, and mapped through
AI_ORB_FACE_MOUTH_STATE_MOOD. It also selects a motion profile and a
saturation. |
expression | AiOrbFaceMouthMood | - | Deprecated alias for mood. |
painted | boolean | false | Paint the sphere as a gradient instead of mounting the WebGL orb. A browser keeps only about sixteen live contexts per page, so a grid of faces — a gallery, an assistant list — runs out of them. The painted sphere looks the same at small sizes and costs no GPU. |
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>. |
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. |
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. 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 face stays legible against it. |
amplitude | number | MotionValue<number> | - | A 0–1 speech level. 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.aiOrbFaceMouth 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 |
mouth | - | `` |
Accessibility
- Pass
ariaLabelwhen the character carries meaning. The SVG then renders asrole="img"with a<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 mood it is in. Mood is visual and fast-changing; announcing it on every transition would be noise. If a mood 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 accents are not clipped. 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, mood loops, accents and the speaking mouth all resolve to their resting state. The face still shows the correct mood, and it is never blank — a static character is a character. A talking face under reduced motion holds an open mouth rather than a closed one, so it still reads as speaking. - The moods are never distinguished by colour alone: eye shape, mouth shape and the accent all differ, so the read survives a monochrome or low-contrast theme.
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. - A mood change is a CSS transition, not a JavaScript tween. The browser runs it while the gaze springs are busy, and it costs no frame budget on the main thread.
- Every mouth shape is mounted and cross-faded, because an SVG path cannot tween
its
dthrough a CSS transition. Cross-fading nine paths is cheaper than interpolating a path string per frame. The speaking mouth is the one shape that moves on its own, and its keyframes are only attached while it is the visible mouth, so the other faces do not run an invisible animation. 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. Orb energy is quantised to four steps before it crosses into a render.- 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 orb also listens for
webglcontextlostand swaps itself for the same gradient, because a lost canvas is not empty: Chrome paints it opaque white. Usepaintedto skip the context altogether when a page shows many faces at once. - 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.