Bruno DelorenceBruno
Appearance
Appearance

Choose your reading theme.

Contact

Status avatar

New

A round avatar whose status dot grows into a tray of three statuses, and shrinks back into the one you pick.

status-avatar/status-avatar.tsx
Live · interactive

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

  • Setting your own presence where your avatar already sits: a sidebar footer, a profile header, a call window.
  • A small fixed set of statuses, two to four, each with its own colour and icon: Online, Focus, Do Not Disturb.
  • A choice made often enough that it should take one hover and one click, without a menu or a settings page.
  • A hero moment in a product tour or a landing page, where the morph from dot to tray is worth showing.

When not to use

  • Showing other people’s status in a list or a chat. Draw a plain avatar with a static dot; only you can change yours.
  • Statuses with free text, an expiry or a schedule (“Back at 3”). Open a menu or a dialog with a field and a duration.
  • More than four statuses: the tray grows past the avatar. Use a dropdown menu with icons.
  • Tiny avatars, under about 60 px, as in a dense table. The dot is too small to aim at; put the setting in the account menu.
  • Pages that must stay still. The springs, blur and glint are the point; with reduced motion they are dropped, but a plain select is lighter.

You can add this component with the shadcn CLI or by hand:

Into components/status-avatar of any React project, with no setup:

Terminal
npx shadcn@latest add https://brunodelorence.com/r/status-avatar.json

Either way it needs React 19.2 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

profile-status.tsx
import { useState } from 'react';import { StatusAvatar, type Status } from './status-avatar';export function ProfileStatus() {  const [status, setStatus] = useState<Status>('online');  return <StatusAvatar status={status} onStatusChange={setStatus} autoplay={false} />;}

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.

The status dot on a profile picture. Rest the pointer on it, click it or press Enter, and the dot swells into a tray of three round options, Online, Focus and Do Not Disturb, while the avatar grows a little with it. A label over the tray names the option under the pointer, its words changing letter by letter. Pick one and the tray shrinks back into a dot of that colour, with a glint across it.

  • One morph, not two widgets: the tray is the dot’s white ring grown from 88 to 368 × 128 design units, and the three options are the dot’s coloured middle. Closed, all three sit in the dot at 60% of their size, the chosen one on top; open, each slides out to its place in the row.
  • Every length is a number of design units times --k, one unit in px: from the size prop, or else a fifth of the stage’s width for the avatar, and shrunk with a container query unit on a narrow stage, so the open tray always fits beside the avatar.
  • The tray, the options, the label and the avatar (which grows by 19%) move on springs written as CSS linear() easings: a softer one that opens (damping 0.65, a 6% overshoot, settled in about half a second) and a firmer one that closes (damping 0.78, 2%).
  • On the way the options blur and land sharp, the ones not chosen fading in on the way out and out on the way back; the dot’s softer colour turns to the tray’s brighter one and the white icons fade in. The blur, and the glint that sweeps the dot after a choice, are played with the Web Animations API.
  • The label is a pill as wide as its words, measured from hidden copies, centred over the named option but kept inside the tray’s ends. It slides and resizes on the opening spring, and its new words blur in letter by letter, 16 ms apart, over the old ones blurring out.
  • A short rest on the dot opens it; a click within 280 ms of that hover is taken as aimed at the dot, not at the option now under the pointer. A tray the hover opened closes a moment after the pointer leaves the avatar; any tray closes on a choice, Escape or a press elsewhere.
  • While on screen and untouched, a demo pointer opens the tray, looks over the options and picks one, then the next, in a loop; any input from the reader stops it.
  • Every colour is a token on the root, light-dark() where the two schemes differ: at night the stage turns near-black and the tray and label a dark grey, the statuses keeping their colours.
PropTypeDefaultDescription
status'online' | 'focus' | 'busy'—The status shown, when the parent keeps it; pair it with onStatusChange.
defaultStatus'online' | 'focus' | 'busy''online'The status it starts with, when it keeps its own.
onStatusChange(status: Status) => void—Called with the status the reader picks.
sizenumber—The avatar’s width in px; the dot, the tray and the label follow. Without it, a fifth of the stage’s width, from 120 to 193 px. Either way it shrinks on a stage too narrow for the open tray.
labelstring'Your status'What the dot’s button is called for screen readers, before the status itself.
autoplaybooleantrueWhile on screen and untouched, a demo pointer opens the tray and picks a status; any input from the reader stops it.
defaultOpenbooleanfalseStarts with the tray open and the label on the current status, as a still for docs and screenshots; it turns the autoplay demo off. A click elsewhere closes it as usual.
colorScheme'light' | 'dark' | 'system'—Light or dark colours, or the reader’s; without it, the page’s color-scheme.
classNamestring—A class for the stage.

The complete source behind this demo. Open source under the MIT license, © 2026 Bruno Delorence: copy it, change it, ship it.

status-avatar.tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import {  useEffect,  useEffectEvent,  useId,  useLayoutEffect,  useRef,  useState,  type CSSProperties,  type KeyboardEvent as ReactKeyboardEvent,  type MouseEvent as ReactMouseEvent,  type PointerEvent as ReactPointerEvent,} from 'react';import { AvatarArt } from './avatar-art';import { DemoPointer, runDemo, type Point } from './demo-pointer';import { useAutoplay } from './use-autoplay';import styles from './status-avatar.module.css';/* * Status avatar: a round avatar with its status dot at the top right. Hovering the dot (or * clicking it, or pressing Enter on it) grows the dot into a tray of three options, Online, Focus
status-avatar.module.css
/* Status avatar (status-avatar.tsx): a round avatar on a quiet grey stage, its status dot at the   top right. Opened, the dot grows into a tray of three round options, the avatar grows with it,   and a label names the option under the pointer or the focus.   Sizes. Every length is a number of design units times --k, the px of one unit: the avatar is   241 units across, the open tray 368 × 128. --k comes from the size prop (--k-max), or without it   from the stage's width (an avatar a fifth of it, 120 to 193 px), and shrinks on a narrow stage, so the open tray always fits beside the avatar (a container query unit).   Motion. The tray, the options and the avatar move on springs written as linear() easings:   a softer one that opens (a 6% overshoot on the avatar and the label, 4.6% on the tray, settled   in about half a second) and a firmer one that closes (2%). The blur the options go through, and the glint on the dot after a choice, are   played from the script with the Web Animations API.   Colours are the tokens below, light-dark(light, dark) where the two differ: the component takes   the color-scheme it inherits from the page, or the one its colorScheme prop sets. */.root {  /* The stage. */  --stage: #f6f6f6;  --line: rgb(0 0 0 / 0.05);  /* The tray and the dot's ring, their edge and lift. */  --tray: #f8f8f9;  --tray-edge: rgb(0 0 0 / 0.11);  --tray-shine: #fff;
avatar-art.tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.import { useId } from 'react';/* * The avatar's picture: an illustrated, made-up character in a leafy valley, drawn once in SVG * on a 240-unit square that the avatar's circle crops. A pale sky, two ranges of hills, a tree * leaning in from the left, and bushes with small flowers in front of a smiling, bearded * character in a dark tee. * * The foliage is many small leaves, laid out once at load from a seeded random sequence, so the * drawing is the same on the server and in the browser, on every render. *//** A small seeded random sequence (mulberry32), so the leaves land in the same places each time. */function sequence(seed: number) {  let state = seed >>> 0;  return () => {    state = (state + 0x6d2b79f5) >>> 0;    let t = state;    t = Math.imul(t ^ (t >>> 15), t | 1);    t ^= t + Math.imul(t ^ (t >>> 7), t | 61);    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;  };
demo-pointer.tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import type { CSSProperties, Ref } from 'react';/* * The demo's pointer, and the steps that move it. A demo is a script of awaited steps: wait, or * glide the pointer somewhere. Stopping the demo (the reader takes over, or it scrolls away) ends * the step in progress at once: its promise rejects, which ends the script right there, and the * pointer fades out. * * Each glide is a curved flight played by the Web Animations API, so nothing runs frame by frame. * The points of the flight are handed to whoever wants to move along with it (nothing here uses * them yet). The arrow and the hand are HTML boxes * that swap by their visibility: a change of style inside an SVG would lay the SVG out again. */export type Point = { x: number; y: number };/** A glide's path and timing, for an animation that plays along with it. */export type Flight = { points: Point[]; timing: KeyframeAnimationOptions };export type Demo = {
use-autoplay.ts
// 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(
  • The dot is a button named with the current status (“Your status: Focus”), with aria-expanded and aria-controls pointing at the options.
  • The options are a radio group: Enter, Space or an arrow key on the dot opens it with the focus on the current status; arrows, Home and End move between the options, Enter or Space picks one, Escape closes. The focus then goes back to the dot.
  • Closed, the options are inert, out of the tab order and out of the accessibility tree.
  • A polite live region says the new status (“Status set to Do Not Disturb”); the label over the tray is visual only, since each option carries its own name.
  • Every control shows a focus ring on keyboard focus. The picture, the icons and the demo pointer are hidden from screen readers.
  • With reduced motion, the tray, the avatar and the label change at once, without springs, blur, letter animation or glint, and the demo does not play.