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.tsxThe 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:
npx shadcn@latest add https://brunodelorence.com/r/status-picker.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
'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.
| Prop | Type | Default | Description |
|---|---|---|---|
statuses | readonly Status[] | STATUSES | The statuses to pick from, five per page; the sixth button pages through them. Each has an id, an emoji and a label. |
defaultStatus | string | null | null | The id of the status set at first. |
defaultPage | number | — | The page of statuses shown at first, 0 being the first five. Setting it turns the demo off. |
defaultTip | number | — | 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. |
autoplay | boolean | true | While 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. |
className | string | — | 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.
// 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 (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. *//* * 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' },'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 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.