@chatwoot/captain-avatar
An animated 3D avatar of Chatwoot's Captain. Its LED-matrix face and the light on its headset mic show what the agent is doing: thinking, searching, listening, speaking and more.
It renders with WebGL2 and has no runtime dependencies. You can use it from plain TypeScript or as a Vue 3 component.
Install
npm install @chatwoot/captain-avatar
Vue is an optional peer dependency. You need Vue 3.3 or later only if you use the component.
Use it
Plain TypeScript
import { createCaptainAvatar, thinking, speaking } from '@chatwoot/captain-avatar';
const avatar = createCaptainAvatar(document.querySelector('#captain')!, {
states: [thinking, speaking],
state: 'thinking',
size: 96,
});
avatar.update({ state: 'speaking' });
avatar.destroy();
createCaptainAvatar turns the element into a size × size box and draws the avatar inside it. Hops, the mic's glow and the floor shadow draw past the box edges without taking part in layout. If a parent has overflow: hidden, it clips them.
Vue
<script setup>
import { ref } from 'vue';
import { CaptainAvatar } from '@chatwoot/captain-avatar/vue';
import { thinking, speaking } from '@chatwoot/captain-avatar';
const states = [thinking, speaking];
const status = ref('thinking');
const onReady = () => console.log('Captain is on screen');
</script>
<template>
<CaptainAvatar :states="states" :state="status" :size="96" @ready="onReady" />
</template>
The component takes the same options as props. It emits the events listed below as ready, unsupported, enter-start, enter-end, state-change and poke. Through a template ref, it exposes poke() and enter().
States
Pass the states you use. idle is always included. Importing only what you use keeps the rest out of your bundle. To include everything, pass allStates.
| State | Face | Motion | Mic light |
|---|---|---|---|
idle | Pill eyes that blink and glance, as in the logo | Looks around, hops now and then | Soft white |
thinking | Eyes with a scan line sweeping down through them in time with the mic, drifting in a slow circle. Now and then one squints or both blink slowly | Head tilted up, switching sides | Amber pulse |
searching | Darting eyes, a beam sweeping the screen | Head sweeps side to side | Cyan flash |
listening | Alert eyes over a live equaliser | Head cocked, small nods | Steady green |
typing | Eyes that read along as the user types, and a blinking caret | Head cocked, a small nod per keystroke | Violet, flashes per keystroke |
speaking | A mouth that moves with the words | Bobs as it talks | Flickers with speech |
working | Eyes looking down, a progress bar | Busy bobbing, a hop with a spin now and then | Blue blink |
happy | Arched eyes and a smile | Bouncy hops | Pink |
error | Red X eyes and a zigzag mouth | Shakes its head | Red blink |
sleeping | Closed eyes and rising z's | Head drooped, slow breathing | Off |
If you pass happy, a click also shows the happy face for a moment.
Showing that the user is typing
Use typing while the user types, and call avatar.keyStroke() from the input's input event:
const avatar = createCaptainAvatar(el, { states: [typing], state: 'typing' });
input.addEventListener('input', () => avatar.keyStroke());
Each call moves the eyes' gaze on, flashes the mic light and nods the head. After 8 keystrokes the gaze sweeps back to the start, like a wrapped line. After about 2 seconds without a keystroke, the eyes return to the centre. In Vue, call keyStroke() on the component's template ref.
Options
| Option | Default | What it does |
|---|---|---|
state | 'idle' | The name of the state to show. An unknown name falls back to idle. |
states | none | The states the avatar can show, in addition to idle. |
size | 160 | The box the avatar rests in, in px. |
theme | classic | The colour set. |
speed | 1 | Multiplier on every animation's speed. |
paused | false | Holds the state's still pose. |
interactive | true | Follows the pointer, and hops and spins on a click. |
floorShadow | true | Draws the soft shadow under the head. |
seed | random | A number from 0 to 1 that offsets the blinks and glances. |
entrance | false | Plays the entrance once the avatar is ready. |
orbit | false | Lets you drag the head round to look at it from any side. It stays where you leave it, and a click turns it back. On touch screens, the avatar takes over touch gestures. |
label | Captain, <state> | The accessible label. |
update(options) changes any of these. A key you leave out keeps its value. A key set to undefined goes back to its default.
Below 64 px, the avatar draws low detail on its own. It drops the grain and the LED grid, and each LED fills its cell so the face stays readable.
Themes
The four themes are classic, midnight, snow and grape. Each is an exported object:
import { createCaptainAvatar, midnight } from '@chatwoot/captain-avatar';
createCaptainAvatar(el, { theme: midnight });
To make your own, pass an object with name, color (the shell), trimColor (the ear cups), headsetColor (the band, cushions and boom) and screenColor (the LEDs). Each colour is a #rgb, #rrggbb or rgb() string.
Entrance
With entrance: true, or when you call avatar.enter(), Captain puts on the headset. The head pops in with its screen off. Then the headset drops on, the cups close and the mic boom swings down. The mic flickers on, then the screen switches on. If you passed happy, the screen opens on the happy face. It takes about 1.3 seconds.
Events
avatar.on(event, listener) returns a function that stops listening.
| Event | Fires when | Detail |
|---|---|---|
ready | The shaders are compiled and the first frame is on screen. | none |
unsupported | The graphics context was lost and didn't come back within 5 seconds. The avatar stops rendering and releases the element. | none |
enterstart | The entrance starts. | none |
enterend | The entrance finishes. If the avatar is still, it fires straight after enterstart. | none |
statechange | update changed the state. | { from, to } |
poke | The avatar was clicked while interactive. | none |
Browser support
The avatar needs WebGL2 running on real graphics hardware. It asks for its context with failIfMajorPerformanceCaveat: true, so a browser that would render WebGL on the CPU counts as unsupported.
Where it isn't supported, createCaptainAvatar leaves the element untouched and renders nothing. The handle it returns has supported: false, and its methods do nothing. The Vue component renders nothing and emits unsupported.
To avoid downloading the avatar where it can't render, run the same check in your app before you load it:
function supportsCaptainAvatar() {
try {
const gl = document.createElement('canvas').getContext('webgl2', {
failIfMajorPerformanceCaveat: true,
});
gl?.getExtension('WEBGL_lose_context')?.loseContext();
return !!gl;
} catch {
return false;
}
}
Performance
Each frame, the CPU runs the motion, writes under a kilobyte of LED values and makes up to three draw calls. The GPU draws everything else from shaders.
The frame rate follows what the avatar is doing:
- While something moves: a hop, the entrance, a face change, the pointer or an active state runs at the display's full rate.
- Calm
idle: capped at 60 fps, so a still avatar doesn't keep a 120 Hz display busy. - Calm
sleeping: capped at 30 fps. - Off screen, paused or in a hidden tab: nothing renders.
With prefers-reduced-motion: reduce, the avatar draws the state's still pose and doesn't animate.
Load it lazily
Bundlers split a dynamic import() into its own chunk. Load the avatar that way so it stays out of your main bundle:
import { defineAsyncComponent } from 'vue';
const CaptainAvatar = defineAsyncComponent(() =>
import('@chatwoot/captain-avatar/vue').then((m) => m.CaptainAvatar),
);
Import everything else from the package, such as states and themes, inside the same lazily loaded code. A normal import anywhere else pulls the shared code back into the main bundle.
Custom states
A state is a plain object, so you can write your own. It has:
name: the name you pass asstateface(grid, params): draws the face on a 24 × 18 LED area each framelight: the LED colour (screen), the mic colour (mic) and the mic brightness over time (pulse)motion(context): optional, sets the head's target pose each steprest: optional, the still pose for reduced motion andpausedfollowsPointer: optional, turns the head toward the pointercalmFps: optional, caps the frame rate while the state is calm
The package exports the drawing helpers the built-in faces use, including eyes, pill, rect, sprite and put. The files in src/states/ are working examples.
Development
bun install
bun run dev # the playground
bun run build # dist/, with the shaders minified
bun run typecheck
The playground has live mini avatars for every state, theme, size and speed controls, an event log, and a debug panel with frame stats and a pose hold.
License
MIT. See LICENSE.
Credits
The head's design, lighting model, motion and LED faces started as a port of robot-heads by Fayaz Ahmed, under the MIT License. Its license is in LICENSE.robot-heads.