Visibility picker
NewAn inline quick menu: a Private pill that opens a two-choice menu above it and rolls to the new pick.
visibility-picker/visibility-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
- Saying who can see one thing, right where it is named: “This board is
Private”, in a share row, a header or a sentence. - Two to four short, mutually exclusive choices that read well as one word each, with an icon to tell them apart at a glance.
- A setting changed now and then, in place, where a full dropdown or a settings page would be too much ceremony.
- Inline in text: with
inline, every size follows the type around it, so it sits on the line like a word.
When not to use
- More than four choices, or choices that need a line of explanation each. Use a dropdown menu or a radio group.
- Changing access for named people or teams. Use a share dialog with a people picker and per-person roles.
- An on/off setting with no room for a third state. A switch says it in one tap.
- Several settings edited together and saved at once, in a form. Use a select or a segmented control there.
- Near the top edge of a scrolling box: the menu opens above the pill and would be cut off. Give it room, or use a menu that flips.
You can add this component with the shadcn CLI or by hand:
Into components/visibility-picker of any React project, with no setup:
npx shadcn@latest add https://brunodelorence.com/r/visibility-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.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
import { VisibilityPicker, LockIcon, GlobeIcon } from './visibility-picker';export function ShareRow() { const [visibility, setVisibility] = useState('private'); return ( <p> This board is{' '} <VisibilityPicker inline value={visibility} onChange={setVisibility} options={[ { value: 'private', label: 'Private', icon: <LockIcon /> }, { value: 'public', label: 'Public', icon: <GlobeIcon /> }, ]} /> </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.
A small grey pill that says who can see something. Press it and it sinks and springs back while a menu of the choices rises out of a blur just above it, its chevron turning over. Pick one and the menu sinks away, the icon cross-fades and the words roll over letter by letter. Any choices, each with an icon; keyboard, live region and both colour schemes included.
- Every size is in em of the pill’s own 12 px type: inline, the picker takes the size of the text around it; on the stage it is drawn larger from the stage’s width by a container query.
- Pressing the pill scales it to 82% in 185 ms; letting go, it springs back over 420 ms along a linear() curve sampled from an exponential with a 75 ms time constant, so most of the way is covered in the first 100 ms.
- The menu rests sunk 1.6em, blurred and transparent, behind the pill. Opening, it fades in over 170 ms, comes into focus over 260 ms and rises over 340 ms on an ease-out a little softer than a quadratic one; closing, it falls, blurs and fades back in about 230 ms, then turns invisible.
- The chevron turns 180° clockwise as the menu opens and back as it closes. The pill darkens while the menu is open; a choice not chosen fades a little under the pointer.
- A new pick swaps the pill’s icons through a blur and rolls its words: the letters the two words share from the start stay; each other letter of the old word rises and fades as the new one rises in from below, 50 ms after its left neighbour. The pill eases to its new width with the Web Animations API.
- Every motion is a CSS transition or animation on transform, opacity or filter, keyed to data attributes. The demo sets the same attributes a real pointer does: it presses the pill, then picks the other choice, every few seconds while on screen, and hands over at the first real input.
- Every colour is a light-dark() token on the root, so the picker follows the page’s color-scheme, or the colorScheme prop.
| Prop | Type | Default | Description |
|---|---|---|---|
options | { value: string; label: string; icon?: ReactNode }[] | Private and Public | The choices, in the menu’s order. Icons are drawn at 1em in currentColor; LockIcon and GlobeIcon are exported. |
value | string | — | The chosen value, when the parent keeps it; pair it with onChange. |
defaultValue | string | — | The value chosen at first, when the picker keeps it. Without it, the first option. |
onChange | (value: string) => void | — | Called with the value of a newly picked choice. |
label | string | 'Visibility' | What the picker is for, for screen readers: the pill reads “Visibility: Private” and names the menu. |
inline | boolean | false | Only the picker, at the size of the text around it, without the stage or the demo. |
autoplay | boolean | true | While on screen and untouched, a demo pointer opens the menu and picks the other choice. |
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. |
defaultState | 'rest' | 'hover' | 'pressed' | 'open' | — | Opens the picker in a given state, for docs and screenshots: under the pointer, held down, or with its menu up. The first real input takes over from it. Setting it turns the demo off. |
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, useEffectEvent, useId, useRef, useState, type KeyboardEvent as ReactKeyboardEvent, type ReactNode,} from 'react';import { DemoPointer, runDemo, type Point } from './demo-pointer';import { ChevronIcon, GlobeIcon, LockIcon } from './icons';import { RollingLabel } from './rolling-label';import { useAutoplay } from './use-autoplay';import styles from './visibility-picker.module.css';/* * Visibility picker: a small inline quick menu. At rest it is a grey pill, "Private ⌄". Pressed, * the pill sinks and springs back as a menu of the choices rises from behind it, blurred, and * comes into focus just above; its chevron turns over. A choice closes the menu, which sinks and * blurs away, and the pill takes the choice's icon and words: the icons cross-fade through a/* Visibility picker (visibility-picker.tsx): a grey pill that opens a two-part menu above it. Every size is in em of the pill's own 12 px type: inline, the picker takes the size of the text around it; on the lab's stage, the scene draws it larger from the stage's width (a container query). 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). Motion. Everything moves by transform, opacity and filter. The curves written as linear() are sampled from the motion they copy: the pill springs back from a press along an exponential (a time constant of 75 ms), and the menu rises along an ease-out a little softer than a quadratic one. */.root,.inline { /* The stage. */ --surface: #fefefe; /* The pill: at rest, under the pointer, pressed, and while its menu is open. */ --pill: #f5f5f5; --pill-hover: #f1f1f1; --pill-press: #f0f0f0; --pill-open: #e5e5e5; /* The pill's words, and its icon and chevron. */ --pill-ink: #525252;// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import { useLayoutEffect, useRef, useState, type CSSProperties } from 'react';import styles from './visibility-picker.module.css';/* * The pill's words, which roll letter by letter when they change: each letter of the old word * rises and fades out as the new word's letter in its place rises in from below, left to right, * 32 ms apart. Letters the two words share from their start ("P" in Private and Public) * stay where they are. * * The new word sits in the flow and gives the box its width; the old one lies over it, out of the * flow, until its letters have gone. The box's width eases from the old word's to the new one's, * so the pill narrows or widens with it. Under reduced motion the words simply swap. *//** How long the old word stays, in ms: its last letter's delay plus its exit. */const LINGER = 700;/** An element's laid-out width in px, to the fraction and before any transform. */const widthOf = (element: HTMLElement) => parseFloat(getComputedStyle(element).width) || 0;// Copyright (c) 2026 Bruno Delorence. MIT License./* * The picker's three drawings, each on a 48-unit square that the stylesheet sizes to 1em (12 px * at the picker's own size). They take the colour of the text around them (currentColor), so the * same lock is grey in the pill and black in the menu. *//** A padlock, filled: a rounded body under a thick shackle. */export function LockIcon({ className }: { className?: string }) { return ( <svg className={className} viewBox="0 0 48 48" aria-hidden="true" focusable="false"> <rect x="7" y="18.2" width="34" height="28" rx="5.5" fill="currentColor" /> <path d="M14.4 20.2V11.2A9.6 9.6 0 0 1 33.6 11.2V20.2" fill="none" stroke="currentColor" strokeWidth="5.6" /> </svg> );}/** A globe in outline: the rim, a meridian oval, the axis, the equator and two parallels. */// 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. */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 = { /** Resolves after `ms`. */ wait: (ms: number) => Promise<void>;// 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 pill is a button with aria-haspopup="listbox" and aria-expanded, named with its purpose and its value (“Visibility: Private”). Enter, Space or the arrow keys open the menu; the arrow keys open it with the focus on the chosen choice.
- The menu is a labelled listbox of options with aria-selected, in a roving tab order: the arrow keys move between the choices, Home and End go to the ends, Enter or Space picks one and gives the focus back to the pill, Escape closes it the same way, and Tab or a press anywhere else closes it.
- Closed, the menu is inert and invisible, so neither the focus nor screen readers reach it. A new pick is announced in a polite live region; the icons and the rolling letters are hidden from screen readers.
- Every control shows a focus ring. Under reduced motion the menu appears and goes without moving or blurring, the words swap at once and the demo never plays.