Bruno DelorenceBruno
Appearance
Appearance

Choose your reading theme.

Contact

Status picker

A row of quick statuses with one sliding tooltip: pick one and its icon arcs down into the status button.

status-picker/status-picker.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 a presence or availability status from a short, known list: in a meeting, out sick, on vacation.
  • A handful of choices that read at a glance as icons, each with a short label shown on hover or focus.
  • One status at a time that can be cleared, where the current one should stay visible as a labelled button.
  • A profile menu, a chat header or a team sidebar, where the picker lives in a small, fixed spot.

When not to use

  • Free-text statuses or a custom message. Pair a text field with a small emoji menu instead.
  • Several values at once (tags, filters). Use checkboxes or toggle chips.
  • Long lists of options, or options without a clear icon. Use a select or a menu with words.
  • Choices the reader must compare by their words: the labels only show one at a time, in a tooltip.
  • Touch-first screens where nothing hovers: the labels never show, so the icons must speak for themselves.

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

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

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

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

profile-status.tsx
'use client';import { useState } from 'react';import { StatusPicker } from './status-picker';import type { Status } from './statuses';export default function ProfileStatus() {  const [status, setStatus] = useState<Status | null>(null);  return (    <>      <StatusPicker onChange={setStatus} autoplay={false} />      <p>{status ? `${status.emoji} ${status.label}` : 'No status'}</p>    </>  );}

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.

Hover or focus along the row and a single tooltip glides from button to button, its width easing to each label. Pick a status and its icon flies down into the button below, which widens to fit the new label and offers a clear button. A sixth button pages through more statuses.

  • One tooltip serves the whole row. It slides to the hovered or focused button, eases its width to the label (measured as it comes up), cross-fades the labels with a touch of blur, and stays within the row’s ends.
  • The tooltip moves inside a fixed-size, clipped track that is its own small layer. The bubble only repaints that layer as it slides, so a phone never rebuilds the layer on every frame.
  • Picking a status runs FLIP on the status button: it is measured before and after, and its width springs between the two with the Web Animations API and a linear() spring. Meanwhile a copy of the emoji flies along an arc from the row, swelling and tilting, and the real icon takes over with a bounce.
  • The previous icon and label stay in the button, invisible by default. Their exit animation starts from visible, so nothing has to clean them up.
  • The sixth button pages five at a time: old icons blur out, new ones pop in from left to right.
  • Every colour is a light-dark() token on the root, so it follows the page’s color-scheme, or the colorScheme prop; at night the round buttons sit a step lighter than the row, and the emoji stay as they are.
PropTypeDefaultDescription
statusesreadonly Status[]STATUSESThe statuses to pick from, five per page; the sixth button pages through them. Each has an id, an emoji and a label.
defaultStatusstring | nullnullThe id of the status set at first.
defaultPagenumber—The page of statuses shown at first, 0 being the first five. Setting it turns the demo off.
defaultTipnumber—The button whose tooltip shows at first, as if hovered: 0 to 4 are the statuses, 5 the paging button. Setting it turns the demo off.
onChange(status: Status | null) => void—Called with the new status, or null when it is cleared.
autoplaybooleantrueWhile on screen and untouched, a demo pointer hovers along the row, picks a status, pages and clears it.
colorScheme'light' | 'dark' | 'system'—Its colours: light, dark, or the reader’s system setting. Without it the component follows the page’s color-scheme, which is light unless the page sets one.
classNamestring—Extra class for the root, a stage of its own.

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

status-picker.tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import {  useEffect,  useLayoutEffect,  useRef,  useState,  type CSSProperties,  type FocusEvent,  type KeyboardEvent,  type PointerEvent,} from 'react';import { GhostPointer, runDemo } from './ghost-pointer';import { STATUSES, type Status } from './statuses';import { useAutoplay } from './use-autoplay';import styles from './status-picker.module.css';/* * Status picker: a row of quick statuses over the button that shows yours. * * - Hover or focus a status and its label shows in a tooltip above it. The row has one tooltip: *   moving to the next status slides it over, its width eases to the new label and the two
status-picker.module.css
/* Status picker (status-picker.tsx). Sizes are in em: the stage sets the font-size from its own   width (a container query), so the picker grows with the room it has. --spring, the easing of   the springy transitions, comes from the component, which uses it for its own animations too.   Colours are the tokens below, light-dark(light, dark) where the two differ: the component 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 statuses' emoji are the same on both. */.root {  /* The stage, and what is raised on it: the row, the tooltip and the status button. */  --surface: #efeff3;  --surface-2: #fff;  /* The words and the ring round the status that is set; the paging button's dots, the empty     status and the clear button's cross. */  --ink: #1c1c1e;  --ink-muted: #8e8e93;  /* The round buttons, then under the pointer; the status button under the pointer. */  --wash: #f1f1f5;  --wash-strong: #e6e6ec;  --hover: #fafafc;  /* The stage's edge, and the hairline round what is raised. */  --line: rgb(0 0 0 / 0.05);  --edge: rgb(0 0 0 / 0.06);  /* What is raised sits on its hairline, a shade right under it and a soft drop. */
statuses.ts
/* * The statuses to pick from. The picker shows them five at a time; its sixth button pages * through the rest. */export type Status = {  id: string;  /** Its icon: on its button, then in the status once it is set. */  emoji: string;  /** The status itself: the tooltip over its button, then the status's text once it is set. */  label: string;};export const STATUSES: readonly Status[] = [  { id: 'vacation', emoji: '🌴', label: 'On vacation' },  { id: 'sick', emoji: '🤒', label: 'Out sick' },  { id: 'meeting', emoji: '📅', label: 'In a meeting' },  { id: 'focus', emoji: '🎧', label: 'Focusing' },  { id: 'commuting', emoji: '🚌', label: 'Commuting' },  { id: 'lunch', emoji: '🥪', label: 'Out for lunch' },  { id: 'remote', emoji: '🏡', label: 'Working from home' },  { id: 'walk', emoji: '🚶', label: 'Out for a walk' },  { id: 'travel', emoji: '✈️', label: 'Travelling' },  { id: 'away', emoji: '🌙', label: 'Off for the day' },
ghost-pointer.tsx
'use client';import type { CSSProperties, Ref } from 'react';/* * The demo's ghost pointer, and the timeline that moves it. A demo is a script of awaited steps: * wait, glide the pointer onto a control, press it and call the component's own handler, as a * click would. 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. */export type Demo = {  /** Resolves after `ms`. */  wait: (ms: number) => Promise<void>;  /** Glides the pointer onto `target`: its middle, or a point given as shares of its size. */  moveTo: (target: Element | null | undefined, at?: { x: number; y: number }) => Promise<void>;  /** Presses and lets go; `action` runs as the pointer goes down. */  press: (action: () => void) => Promise<void>;};/** What a stopped demo's pending step rejects with. */class Stopped extends Error {}
use-autoplay.ts
'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(      ([entry]) => {        onScreen = entry.isIntersecting;
  • The row is a toolbar with one tab stop: the arrow keys, Home and End move along it, and Enter or Space picks. Statuses are toggle buttons (aria-pressed), and pressing the one that is set clears it.
  • The tooltip appears on hover or keyboard focus, never after a tap. It is hidden from screen readers, since each button carries its own label, and Escape dismisses it.
  • Changes are announced (“Status set: In a meeting.”, “Status cleared.”). When the clear button goes away, focus moves to the status button.
  • Under reduced motion nothing flies or slides, and the demo does not run.