AI

Assistant Avatar

An assistant identity chip whose ring carries the state — a portrait or a seed-derived token gradient inside, ripples while listening, a conic sweep while thinking, equalizer ticks while speaking.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { cn } from "@/lib/utils"

/* -------------------------------------------------------------------------- *
 * Keyframes
 *
 * They ship with the component through a React 19 hoisted <style> — no Tailwind
 * config edits, and every instance on the page dedupes by href.
 *
 * The sweep animates a REGISTERED custom property instead of rotating the arc
 * element. Rotating the element would rotate its border-radius too, so the
 * squircle variant would visibly tumble; animating `--zy-aa-angle` moves only

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/assistant-avatar.json

Prompt

The prompt behind this component — paste it into your AI assistant to recreate or adapt it.

Build a React + TypeScript + Tailwind "AssistantAvatar" component: one small
round (or squircle) chip that says WHO an assistant is and WHAT it is doing
right now. Identity lives in the face, state lives in the ring around it. No
animation library, no audio, no network — everything is CSS keyed off one table.

Contract
- forwardRef<HTMLSpanElement>, extends React.HTMLAttributes<HTMLSpanElement>
  minus children; remaining props spread on the root; className merged with cn().
- state?: "idle" | "listening" | "thinking" | "speaking" | "offline" | "error"
  (default "idle").
- src?: string — portrait URL (DiceBear, an uploaded avatar, anything).
- name?: string (default "Assistant") — drives the initials, the caption and the
  announcement.
- model?: string — second caption line (model id, version, role).
- seed?: string (defaults to `name`) — palette seed for the gradient face.
- fallback?: React.ReactNode — replaces the initials with an icon or a glyph.
- size?: "xs" | "sm" | "md" | "lg" | "xl" | number (default "md") →
  24 / 32 / 40 / 56 / 80 px; a raw number is clamped to >= 16 and rounded.
- shape?: "circle" | "squircle" (default "circle").
- caption?: "none" | "below" | "beside" (default "none").
- ticks?: number (default 8, clamped 0..16) — bars in the speaking equalizer.
- speed?: number (default 2.4) — seconds per idle cycle; every state scales its
  own effect cycle from it.
- animate?: boolean (default true) — false keeps the colored ring, drops motion.
- labels?: Partial<Record<State, string>> — default wording is Ready / Listening
  / Thinking / Speaking / Offline / Unavailable.
- announce?: boolean (default true), announceDelay?: number (default 400).
- Non-finite numbers (NaN, Infinity) fall back to the defaults instead of
  producing `NaNpx` or an empty Array.from.

Behavior — the state table
- Four parallel Record<State, …> tables and nothing else branches: TONE (a theme
  variable per state), TRACK (how strong the always-painted ring is), CYCLE (a
  multiplier on `speed`), LABELS. Adding a seventh state must be four rows.
- TONE: idle → --primary, listening → --chart-2, thinking → --chart-1,
  speaking → --chart-4, offline → --muted-foreground, error → --destructive.
  The root publishes the winner as --zy-aa-tone and every layer paints with that
  one variable, so a brand override is a single custom property.
- Only ONE effect layer is mounted at a time, and only for its own state. An
  idle / offline / error avatar renders three nodes and starts zero animations —
  the difference between a 50-row roster that scrolls and one that stutters.
- offline additionally dims and desaturates the face (opacity + grayscale
  filter) on a transition; error is deliberately motionless — a failure that
  dances reads as progress.

Behavior — the three effects
- listening: two rings scaling 0.80 → 1.04 with an opacity envelope, one of them
  a negative half-cycle out of phase. They stop just past the rim, so a row of
  avatars never grows into its neighbour and no state changes the layout.
- thinking: ONE conic-gradient wedge (transparent → tone at ~115deg →
  transparent at ~200deg) that sweeps by animating a registered custom property:
  @property --zy-aa-angle { syntax: "<angle>"; inherits: false;
  initial-value: 0deg } plus @keyframes to { --zy-aa-angle: 360deg }, read as
  conic-gradient(from var(--zy-aa-angle), …). Do NOT rotate the element: that
  would rotate its border-radius too and the squircle would visibly tumble.
  Where @property is unsupported the wedge simply stands still and still reads.
- speaking: `ticks` bars around the rim. Each bar sits in its own wrapper that
  is statically rotated (360/n * i deg); the bar itself animates scaleY 0.28 → 1
  from origin-top. Centre it with a NEGATIVE MARGIN (-w/2), never with a
  translate utility: the bar's own transform is the animation, and Tailwind puts
  translate on the `transform` shorthand in v3 and on the standalone `translate`
  property in v4 — one version loses the centring, the other doubles it.
- Stagger the bars with a golden-ratio offset (delay = -((i * 0.618) mod 1) *
  cycle) so neighbours never peak together and the ring reads as a voice rather
  than as a rotating cog. Per-bar delay goes in an inline style (which outranks
  the class), while the listening pair's half-cycle offset rides in the
  `animation` SHORTHAND's own delay slot — a separate `animation-delay` utility
  has no guaranteed order against the shorthand that resets it.

Behavior — the face is the mask
- The face is an opaque element inset 11%, painted AFTER every effect layer.
  That is what crops a full conic disc into an arc and a full-length bar into a
  tick — no mask-composite, no per-shape geometry, works at any border-radius.
- Face fill: linear-gradient(145deg, color-mix(in oklab, A 30%, var(--card)),
  color-mix(in oklab, B 18%, var(--card))) where A and B are two DISTINCT
  --chart-N tokens chosen by hashing the seed (FNV-1a; pick b = (a + 1 + h>>8 %
  4) % 5 so a face is never a flat disc). Same seed → same face on every machine
  and every reload, with nothing stored.
- Keep that gradient under the portrait as well: DiceBear SVGs are transparent,
  and an avatar with a transparent background must land on the palette, not on a
  hole through which the sweep would show.
- Fallback chain: portrait → gradient + `fallback` node → gradient + initials
  (first letters of the first two words, uppercased, "AI" if the name is empty).
  Remember WHICH url failed (useState<string | null>), not a boolean: a one-shot
  lock per URL means a later src change retries by itself instead of being stuck
  on letters forever. Size the initials as calc(size * 0.34) and any SVG child
  at 46% of the face, so one geometry serves 24px and 80px.

Behavior — announcement
- The ring follows `state` on the frame it changes; the announcement lags by
  announceDelay (setTimeout, cleared on every state change and on unmount) so a
  turn that flips listening → thinking → speaking in 300ms is spoken once, not
  three times. announceDelay <= 0 derives the spoken state in render — no timer,
  no cascading update.
- The visual is aria-hidden="true" in every configuration; a permanently mounted
  sr-only <span role="status" aria-live="polite"> carries "<name>, <label>".
  Mount it always: several screen readers skip a live region that appears in the
  same frame as its text. Include the name — on a page with three assistants a
  bare "Thinking" belongs to nobody.
- announce={false} drops role/aria-live but KEEPS the sr-only text, so the state
  is still readable when the surrounding list owns the region.

Rendering & styling
- Semantic tokens only: --primary, --chart-1..5, --muted-foreground,
  --destructive, --card, text-foreground, bg-card, border-solid. Every alpha is
  a color-mix(in oklab, <token> N%, transparent). No hex, no rgb(), no oklch().
- Geometry is one set of custom properties on the root: --zy-aa-size,
  --zy-aa-ring = max(1.5px, size * 0.055) (the floor is what keeps a 24px chip
  visible), --zy-aa-band = size * 0.12 (tick length, 1% longer than the face
  inset so a peaking bar tucks under the portrait), --zy-aa-tick = ring * 1.15
  (bar width), --zy-aa-radius and --zy-aa-face-radius. Nothing is hard-coded per
  size tier. The inner radius is DERIVED, not repeated: for a squircle
  (30% − 11%) / 78% ≈ 24%, so the two corners stay concentric.
- Keyframes and the @property rule ship inside the component through a React 19
  hoisted <style href precedence> — no Tailwind config edits, and every instance
  on the page dedupes by href.
- prefers-reduced-motion: every effect layer is motion-reduce:hidden and the
  transitions are motion-reduce:transition-none. What remains is the track ring,
  which is why its WEIGHT moves with the state too (idle 26%, thinking 64%,
  listening / speaking 72%, error 82%) — hue alone is not a state indicator.
  The live region is untouched, so nothing about the state is lost.
- Root is an inline-flex <span> with align-middle so it can sit inside a
  sentence, a heading or a button label; caption="below" switches it to a
  column. The caption truncates instead of widening its container.

Customization levers
- Ring weight and gap: --zy-aa-ring (0.055 of the diameter) and the face's 11%
  inset are the two numbers that decide how loud the chip is. Thicker ring +
  bigger inset = a wearable-style status ring; thinner = a quiet chat header.
- Palette: set --zy-aa-hue on the element (or any ancestor) to pin every state
  to one brand color, or remap the TONE table if a state should read differently
  in your product. Swap --chart-N for --primary/--secondary in FACE_TOKENS to
  make gradient faces monochrome.
- Effects: `ticks` (0 removes the equalizer entirely), `speed` (raise for a
  calmer assistant, lower for an eager one), and animate={false} for lists.
  Any effect can be deleted independently — each is one JSX branch.
- States: drop `offline`/`error` if your session layer cannot produce them, or
  add "queued"/"muted" by extending the four tables plus one branch.
- Identity: caption="beside" for chat headers, "below" for pickers, "none" for
  message rows; swap the caption block for a Badge, a model chip or a link.
- Shape: SHAPE is a two-entry table — add "rounded" with outer 24% / inner 17%
  to fit a squarer design system.

Concepts

  • Identity plus state — the face answers "which assistant is this" and the ring answers "what is it doing"; keeping them on two independent axes means a turn can change the ring sixty times without ever touching the portrait, and a roster can swap assistants without touching the state machine.
  • Face as mask — the opaque face is painted last and inset 11%, so it crops whatever the effect layers drew into a thin band: a full conic disc becomes an arc, a full-length bar becomes a tick. No mask-composite, and the trick survives any border-radius, which is what lets the circle and the squircle share one geometry.
  • Mount on demand — only the current state's layer exists in the DOM; idle, offline and error mount nothing at all, so a fifty-row roster of resting assistants starts zero animations instead of fifty.
  • Per-URL fallback lock — the component remembers which URL failed rather than a boolean "broken" flag, so a dead portrait degrades to letters permanently while a later src swap gets a fresh attempt on its own.
  • Dwell-latched announcement — the ring follows the state instantly but the live region waits out announceDelay, so a turn that flips listening → thinking → speaking inside 300ms is spoken once instead of narrating blips the user never saw.
  • Colour-only fallback — under prefers-reduced-motion every effect layer is hidden and the track ring changes hue and weight; hue alone would leave colour-blind readers with nothing, and the polite region still carries the state in words.

On This Page