Bot avatars
NewPlush, furry 3D faces for AI agents that look around, blink, hop at work and doze off, drawn on a plain canvas.
bot-avatars/bot-avatars.tsxBots
- Mail sorterIdle: 12 threads triaged, 4 replies queued
- Research aideWorking: Reading 3 reports on churn
- Night watchSleeping: Asleep until 8:30
Could you sum up the notes from Tuesday’s call?
Three things stood out: the pilot starts in May, legal wants the data terms by Friday, and the budget stays where it was.
Playground
import { BotAvatar } from './bot-avatar';
<BotAvatar type="clover" size={96} />The live component, numbered part by part. Every measurement is read from it as it is drawn. Point at a row to outline its part, or at a part to find its row.
When to use
- A face for each AI agent or assistant in an app: a list of bots, a chat reply, a task card, an empty state.
- Showing what an agent is doing at a glance:
idlelooks around,workinghops on a beat while it thinks,sleepingdozes. - Telling several bots apart: eighteen bodies, five finishes, hats, glasses and any colour, with a
seedso neighbours don’t move in step. - A friendly, tactile touch in a product that talks: it follows the pointer and leaps when poked.
When not to use
- A person. Use a photo or an initials avatar; a plush creature reads as a bot.
- A precise status (queued, failed, 42 % done). Use a badge, a spinner or a progress bar beside it; the avatar’s mood is only a hint.
- Dense tables with hundreds of rows. Use a static icon or a single still frame: each avatar is a canvas, and many live ones cost frames.
- Serious or formal tools (finance, legal, health), where a playful mascot undercuts trust. Use a plain logo mark.
- Below about 24 px, where the fur and the eyes blur into a smudge. Use a coloured dot or an icon.
You can add this component with the shadcn CLI or by hand:
Into components/bot-avatars of any React project, with no setup:
npx shadcn@latest add https://brunodelorence.com/r/bot-avatars.json- Copy the component files
Copy these files into one folder of your project, keeping their relative imports together, or download them as a ZIP:
- Import it
As shown under Usage, below.
Either way it needs React 19 and a bundler that supports CSS Modules, such as Next.js or Vite. It has a dark colour scheme: it follows your page’s color-scheme, light unless the page sets one, or its colorScheme prop, with nothing to configure. Browsers without light-dark() show it light.
Usage
import { BotAvatar } from './bot-avatar';export function AgentRow({ busy }: { busy: boolean }) { return ( <li> <BotAvatar type="flower" size={36} state={busy ? 'working' : 'idle'} label="Research aide" /> Research aide </li> );}Each state below is a live instance, locked so you can study it.
The pieces it is built from, each with its measurements read live.
Avatars for the agents in an app: eighteen soft bodies, from a clover to a little robot, each with two painted eyes. They look around and follow the pointer, blink, and every few seconds hop, spin or flip; at work they bounce on a beat and beam as they land, and asleep they bow with their eyes shut. Five finishes, from fur to glossy plastic, hats, glasses and a dozen dials, all rendered by a small software renderer on a 2D canvas, with no WebGL.
- Each body is a flat outline written as a signed distance function. A grid laid over it is pulled onto the outline at the edge, then lifted into a cushion: the height is the square root of a membrane stretched over the outline and pushed out from behind (a Poisson problem solved once by over-relaxation), so it rounds off steeply at the seam and has no folds where lobes meet. The back is its mirror; the two sheets share the outline.
- Every frame the two sheets are squashed, turned and lifted into place and filled triangle by triangle into a depth buffer, which keeps each pixel’s normal and its spot on the face. Antennas, ear discs, hats and glasses are spheres, cones and tubes found by casting a ray through each pixel of their box into the same buffer.
- Each covered pixel is then lit once: a key light from the upper left with a soft wrap, the lit side washed paler, a back light round the rim and an optional sheen. The colour tuning (brightness and saturation) comes after the light, and saturates the shade more than the light, so shadows deepen instead of turning grey.
- The fur: a few thousand roots scattered evenly over the surface once, each with its own length, shade and lean. Those on the visible side grow along the turned normal and are drawn as fading lines over the lit body; along the outline they stand out against the background, which makes the soft fuzzy edge. Fine noise fixed to the surface breaks up the colour and turns with it. Plastic, smooth and flat skip the fur and are drawn at twice the size, then scaled down, for clean edges.
- The eyes are painted from the face’s own coordinates, so they turn with the body; they slide across the face to look, flatten to a dash to blink, and become arcs to beam (^ ^) or sleep (‿ ‿).
- Behaviour is a spring for the gaze and the eyes plus short timelines for tricks: a crouch, a parabolic hop that stretches on the way up and down, a full turn or flip in the air, and a landing squash that rings out. Squash and stretch keep the volume. Idle avatars look around and do a trick every few seconds; working ones hop on a beat; sleeping ones breathe slowly with a bow. Poking one makes it leap and land beaming.
- All avatars on a page share one animation frame loop, and each draws only while on screen in a visible tab. The pointer is read once per move for all of them; each looks at it while it moves, then goes back to looking around.
| Prop | Type | Default | Description |
|---|---|---|---|
type | 'clover' | 'flower' | 'star' | 'ghost' | 'mech' | 'circle' | 'hexagon' | 'square' | 'triangle' | 'blob' | 'drop' | 'droid' | 'alien' | 'cat' | 'cloud' | 'pill' | 'pebble' | 'puddle' | 'clover' | The body. The props down to label are the avatar’s (BotAvatar); the last three are the showcase’s (BotAvatars). |
size | number | 64 | The box it takes in the layout, in px. The drawing overflows it, a quarter at the sides and more above, so hops and hats have room. |
state | 'idle' | 'working' | 'sleeping' | 'idle' | Idle looks around and does a trick now and then; working hops on a beat; sleeping bows with its eyes shut and breathes slowly. |
shading | 'fabric' | 'plastic' | 'crisp' | 'smooth' | 'flat' | 'fabric' | The finish: fur, glossy plastic, a short crisp pile, smooth velvet, or flat colour. |
hat | 'none' | 'beret' | 'beanie' | 'party' | 'crown' | 'none' | Something to wear on top. |
glasses | 'none' | 'round' | 'square' | 'none' | Frames around the eyes. |
color | string | — | The body’s colour, as hex, before light and tuning (the tuning deepens it); each type has its own without it. |
ink | string | '#1f1b2e' | The eyes’ colour, used as it is. |
furLength | number | 1 | Strand length, 0 to 2. |
brightness | number | 120 | Brightness of the colour, in %. |
saturation | number | 159 | Saturation of the colour, in %. |
shadow | number | 115 | How deep the shading goes, in %. |
backLight | number | 60 | Light round the rim from behind, 0 to 100 %. |
sheen | number | 0 | A soft highlight from the key light, 0 to 100 %. |
depth | number | 65 | Thickness of the body, 0 to 100 %. |
edgeFuzz | number | 90 | How far the fur fluffs out along the outline, 0 to 100 %. |
speed | number | 1 | Speed of every motion, as a multiple. |
paused | boolean | false | Holds it still, facing front, in its state’s resting pose. |
interactive | boolean | true | Looks at the pointer while it moves, and leaps when poked (clicked or tapped). |
seed | number | — | Varies its habits, so avatars side by side don’t move in step. |
label | string | — | A name for screen readers (role img); without it the avatar is decorative and hidden from them. |
autoplay | boolean | true | Showcase: its avatars live on their own and the chat plays while on screen; false holds them still. |
colorScheme | 'light' | 'dark' | 'system' | — | Showcase: its colours, light, dark, or the reader’s system setting. Without it the showcase follows the page’s color-scheme, which is light unless the page sets one. The avatars look the same on both. |
className | string | — | Extra class for the root. |
The complete source behind this demo. Open source under the MIT license, © 2026 Bruno Delorence: copy it, change it, ship it.
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import { useEffect, useId, useRef, useState, type KeyboardEvent } from 'react';import { BotAvatar, type BotAvatarProps } from './bot-avatar';import type { State } from './brain';import type { Glasses, Hat, Shading } from './render';import { SHAPE_NAMES, type ShapeName } from './shapes';import { useAutoplay } from './use-autoplay';import styles from './bot-avatars.module.css';/* * Bot avatars: plush, furry 3D faces for AI agents, drawn on a plain 2D canvas (no WebGL). This * file is the showcase around the avatar itself (bot-avatar.tsx): * * - A row of four, living on their own: looking around, blinking, hopping and spinning now and * then, and following the pointer while it moves. Poke one and it leaps. * - Two scenes from an agent app: a list of bots, one idle, one at work, one asleep; and a chat, * where the bot works while it thinks, then settles as its answer streams in word by word. * - A playground with every option (eighteen bodies, three states, three sizes, five finishes, * hats, glasses, fur, colour, ink and light), and the code for what it shows. * * Colours are light-dark() tokens on the root, so the showcase follows the page's color-scheme or// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import { useEffect, useRef, type CSSProperties } from 'react';import { Brain, type State } from './brain';import { Renderer, type Glasses, type Hat, type Look, type Shading } from './render';import type { ShapeName } from './shapes';import styles from './bot-avatars.module.css';/* * One avatar: a canvas half as large again as the avatar's box (more of it above, so a hop or a hat * has room), with the body drawn in the box by the renderer (render.ts) in the pose its brain (brain.ts) gives * it each frame. * * All avatars on a page share one animation frame loop, and each only draws while it is on * screen, the tab is visible and it isn't paused. Under reduced motion it draws its resting pose * once (and again when a prop changes). The pointer is read once per move for all of them; each * looks at it while it moves, then goes back to looking around on its own. */export type BotAvatarProps = { /** The body. */ type?: ShapeName;// Copyright (c) 2026 Bruno Delorence. MIT License.import type { Pose } from './render';/* * What an avatar does, frame by frame: it breathes, looks around (or at the pointer when one * moves nearby), blinks now and then, and from time to time does a trick: a hop, a hop with a * full turn, a flip, a wiggle. At work it hops on a beat, now and then spinning and squinting * happily as it lands. Asleep it bows a little, eyes shut in two arcs, breathing slowly. * Poking it makes it crouch, leap with a turn and land beaming. * * Every motion is either a spring (the gaze, the eyes) or a short timeline (a trick), added * together into one pose; a trick's squash and stretch keep the body's volume about the same. */export type State = 'idle' | 'working' | 'sleeping';type Trick = { kind: 'hop' | 'spin' | 'flip' | 'wiggle' | 'squash' | 'poke'; start: number; height: number; turns: number; roll: number; happy: boolean;// Copyright (c) 2026 Bruno Delorence. MIT License.import { getMesh, getRoots, ROOT_STRIDE, type Mesh } from './mesh';import { SHAPES, type ShapeName } from './shapes';/* * A small software renderer on a 2D canvas: no WebGL. * * 1. The body's two sheets are turned and squashed into place, and their triangles filled into a * depth buffer pixel by pixel; each pixel keeps the nearest surface's normal and its spot on * the face. Hats, glasses and antennas are solids (spheres, cones, tubes) found by casting a * ray through each pixel of their box, into the same buffer. * 2. Every pixel the body covers is lit once: a soft key light from the upper left, a fill, a * back light round the rim and a sheen, over the body's colour broken up by fine noise that * sticks to the surface. The eyes are painted on the face from the same spot, so they turn * with it. * 3. Fur: thousands of short strands grow out along the surface's normal from fixed roots. Those * on the visible side are drawn as fading lines over the lit body, and along the outline they * stand out against the background, which gives the soft fuzzy edge. * * Smooth finishes (plastic, smooth, flat) skip the fur and are drawn at twice the size, then * scaled down, for clean edges. */// Copyright (c) 2026 Bruno Delorence. MIT License.import { SHAPES, type ShapeName } from './shapes';/* * From a flat outline to a plush body. A square grid is laid over the outline; grid points just * outside it are pulled onto it, so the edge is smooth. Each point is lifted to a height that * grows from nothing at the outline, round like a cushion's seam, to the body's full thickness * a little way in, plus a gentle dome over the whole face. The front is that surface and the back * its mirror, joined at the outline: two triangle sheets, each with a normal at every point. * * Built once per body, thickness and grid size, and shared by every avatar that draws it. The * fur's roots are scattered over the same surface, as many as the size asks for. */export type Mesh = { /** Points, then normals, 3 numbers each: the front sheet's first, then the back's. */ pos: Float32Array; nrm: Float32Array; /** Triangles, as point indices, already wound to face out. */ tris: Uint32Array; /** The lowest point of the outline (the body squashes down onto it) and its top at the middle. */ bottom: number; top: number;// Copyright (c) 2026 Bruno Delorence. MIT License./* * The eighteen bodies. Each is a flat outline written as a signed distance function (negative * inside, in units where the body spans about -1 to 1, y up), which the renderer inflates into a * plush pillow: thick in the middle, rounding off to a thin seam at the outline. A body also says * where its eyes sit, its own colour, how thick it is, and any hard parts it wears (antennas, ear * discs), which are drawn as small glossy solids. */export type ShapeName = | 'clover' | 'flower' | 'star' | 'ghost' | 'mech' | 'circle' | 'hexagon' | 'square' | 'triangle' | 'blob' | 'drop' | 'droid' | 'alien'/* Bot avatars (bot-avatars.tsx and bot-avatar.tsx): near-black cards at night, pale grey ones by day, each with its scene: a row of avatars, a list of bots, a chat, then the playground (a stage and a panel of controls) and the code for what it shows. Colours are the tokens below, light-dark(light, dark) where the two differ: the showcase takes the color-scheme it inherits from the page (light unless the page says otherwise), or the one its colorScheme prop sets (data-color-scheme). The avatars are drawn the same on both. Memory. Each avatar's canvas is its own layer, half as large again as its box, and only redraws while on screen; the cards are fixed sizes, so a redraw never lays out the page. */.root { /* Cards, the playground's panel, and the list's sheet. */ --card: #f3f3f4; --panel: #ffffff; --sheet: #ffffff; --ring: rgb(0 0 0 / 0.07); --lift: rgb(0 0 0 / 0.06); /* Words: strong, captions, quiet, field labels, the list's heading, the bot's reply. */ --ink: #171717; --caption: #8b8b8b; --quiet: rgb(60 60 66 / 0.68); --label: #7b7b7b; --head: #a6a6a6;// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import { useCallback, useEffect, useRef, useState } from 'react';/* * When a demo may play on its own: while part of it is on screen and the tab is visible, never * under reduced motion, and not while the reader is using it. `take()` hands it to the reader; it * takes back over `idle` ms after their last input. */export function useAutoplay<T extends HTMLElement>(idle = 6000) { const ref = useRef<T>(null); const [visible, setVisible] = useState(false); const [reduced, setReduced] = useState(true); const [taken, setTaken] = useState(false); const timer = useRef(0); useEffect(() => { const element = ref.current; if (!element) return; let onScreen = false; const update = () => setVisible(onScreen && !document.hidden); const observer = new IntersectionObserver(- An avatar is decorative (aria-hidden) unless it has a label, which makes it an image with that name. Next to a name, as in a list of bots, leave it decorative.
- The list says each bot’s state in words for screen readers (Idle, Working, Sleeping), not only by how the avatar moves.
- The chat’s reply is a polite live region, busy while the bot thinks and streams, so it is read once when complete.
- The playground’s options are radio groups (arrow keys, Home and End move the choice), the dials are native range inputs with their values spoken, the swatches are toggle buttons with names, and Play is a toggle button. Every control has a visible focus ring.
- Under reduced motion the avatars hold their resting pose (and still change with their state and options), the reply appears at once and Thinking… does not shimmer. They also stop off screen and in hidden tabs.