# Visibility picker

> An inline quick menu: a Private pill that opens a two-choice menu above it and rolls to the new pick.

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.

A React component by Bruno Delorence: https://brunodelorence.com/components/visibility-picker. MIT License, Copyright (c) 2026 Bruno Delorence.

Category: Inputs.

## Anatomy

1. **Menu**: A horizontal listbox on a grey frame, centred above the pill. Closed, it has sunk 1.6em, blurred and faded behind the pill, and is inert.
2. **Chosen choice**: An option with `aria-selected`, its words and icon in the strongest ink. It holds the focus while the menu is open.
3. **Other choice**: The same option in a softer grey; under the pointer its words and icon fade a little.
4. **Tail**: A square turned on its corner, half under the frame, pointing at the pill it belongs to.
5. **Pill**: The button that opens the menu, named with its purpose and value (“Visibility: Private”). It darkens while the menu is open.
6. **Icon**: The chosen option’s icon, at 1em in a light grey; on a new pick the icons cross-fade through a blur.
7. **Label**: The chosen option’s words. They roll letter by letter on a new pick, and the pill eases to the new width.
8. **Chevron**: Points down at rest and turns over, clockwise, as the menu opens.

## 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.

## Installation

Install it with the shadcn CLI, into components/visibility-picker of any React project:

```sh
npx shadcn@latest add https://brunodelorence.com/r/visibility-picker.json
```

Or copy all the source files below into one folder of your project. Either way it needs React 19.2 and a bundler with CSS Modules (Next.js, Vite).

It has a dark colour scheme. Its colours are CSS light-dark() pairs, kept inside @supports so that builds which rewrite light-dark() for older browsers leave them alone: 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

```tsx
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>
  );
}
```

## States

- **Rest**: A grey pill with the chosen icon, words and a chevron.
- **Another value**: The pill takes the choice’s icon and words, and its width follows them.
- **Hover**: The pill lightens a shade under the pointer.
- **Pressed**: Held down, it sinks to 82%; let go, it springs back over 420 ms.
- **Open**: The menu has risen above the pill, the pill has darkened and the chevron points up.
- **Three choices**: Any number of `options`: the inner corners stay small, the outer ends round.
- **Dark**: With `colorScheme="dark"`, or on a dark page, every token takes its dark value.

## How it works

- 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.

## API reference

- **options** (`{ value: string; label: string; icon?: ReactNode }[]`, default `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`, default `'Visibility'`): What the picker is for, for screen readers: the pill reads “Visibility: Private” and names the menu.
- **inline** (`boolean`, default `false`): Only the picker, at the size of the text around it, without the stage or the demo.
- **autoplay** (`boolean`, default `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.

## Accessibility

- 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.

## Source

### visibility-picker/visibility-picker.tsx

```tsx
// 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
 * blur, the words roll letter by letter, and the pill eases to its new width.
 *
 * - Every size is in em of the pill's own 12 px type, so the picker takes the size of the text
 *   around it when used inline, and the lab's stage draws it larger from its width.
 * - The motion is all CSS transitions on transform, opacity and filter, driven by data
 *   attributes: data-open on the picker, data-pressed and data-hover on the pill and the choices.
 *   The demo sets the same attributes the reader's pointer does, so both look the same.
 * - The menu is a listbox of options, in a roving tab order: the arrow keys move between the
 *   choices, Enter or Space picks one, Escape closes and gives the focus back to the pill. A press
 *   anywhere else closes it too.
 * - Light or dark: every colour is a light-dark() token in the CSS, so the scheme is the page's
 *   color-scheme, or the one the colorScheme prop sets.
 */

export type PickerOption = {
  /** What onChange reports. */
  value: string;
  /** The words, in the pill and in the menu. */
  label: string;
  /** A drawing at 1em that takes currentColor, such as LockIcon or GlobeIcon. */
  icon?: ReactNode;
};

export { GlobeIcon, LockIcon };

const DEFAULT_OPTIONS: PickerOption[] = [
  { value: 'private', label: 'Private', icon: <LockIcon /> },
  { value: 'public', label: 'Public', icon: <GlobeIcon /> },
];

/**
 * The demo's beats, in ms from the start of a round, from the frames it copies: the pointer comes
 * to the pill, hovers, presses for a moment and lets go; the menu opens; the pointer goes to the
 * other choice, hovers it and picks it, then moves away.
 */
const DEMO = {
  toPill: 300,
  hover: 250,
  press: 200,
  settle: 350,
  toChoice: 620,
  dwell: 370,
  away: 300,
  rest: 400,
};

export function VisibilityPicker({
  options = DEFAULT_OPTIONS,
  value,
  defaultValue,
  onChange,
  label = 'Visibility',
  inline = false,
  autoplay = true,
  colorScheme,
  defaultState,
  className = '',
}: {
  /** The choices, two or more, each with its value, words and icon. */
  options?: PickerOption[];
  /** The chosen value, when the parent keeps it; pair it with onChange. */
  value?: string;
  /** The value chosen at first, when the picker keeps it. Without it, the first option. */
  defaultValue?: string;
  /** Called with the value of a newly picked choice. */
  onChange?: (value: string) => void;
  /** What the picker is for, for screen readers: the pill reads "Visibility: Private". */
  label?: string;
  /** Only the picker, at the size of the text around it, with no stage and no demo. */
  inline?: boolean;
  /** While on screen and untouched, a demo pointer opens the menu and picks the other choice. */
  autoplay?: boolean;
  /** Light or dark colours, or the reader's ('system'); without it, the page's color-scheme. */
  colorScheme?: 'light' | 'dark' | 'system';
  /**
   * Opens the picker in a given state, for docs and screenshots: under the pointer ('hover'),
   * held down ('pressed') or with its menu up ('open'). The first real input takes over from it.
   * Setting it turns the demo off.
   */
  defaultState?: 'rest' | 'hover' | 'pressed' | 'open';
  className?: string;
}) {
  const { ref: stage, playing, take } = useAutoplay<HTMLDivElement>(5000);
  const [own, setOwn] = useState(defaultValue ?? options[0]?.value);
  const chosen = value ?? own;
  const index = Math.max(
    0,
    options.findIndex((option) => option.value === chosen),
  );
  const current = options[index];

  const [open, setOpen] = useState(defaultState === 'open');
  /**
   * The choice the menu shows as chosen. It is set as the menu opens and holds while it closes,
   * so a new pick shows on the pill alone, as the menu fades.
   */
  const [marked, setMarked] = useState(index);
  const [pressed, setPressed] = useState(defaultState === 'pressed');
  /** The demo's pointer over the pill, or over a choice (its index). */
  const [demoHover, setDemoHover] = useState<number | 'pill' | null>(
    defaultState === 'hover' ? 'pill' : null,
  );
  const [message, setMessage] = useState('');

  const picker = useRef<HTMLSpanElement>(null);
  const pill = useRef<HTMLButtonElement>(null);
  const items = useRef<(HTMLButtonElement | null)[]>([]);
  const pointer = useRef<HTMLSpanElement>(null);
  const id = useId();
  const menuId = `${id}-menu`;

  const demo = !inline && autoplay && !defaultState && playing;

  /** Opens the menu; with `focus`, moves the focus to the chosen option. */
  const show = (focus: boolean) => {
    setMarked(index);
    setOpen(true);
    if (focus) requestAnimationFrame(() => items.current[index]?.focus());
  };
  /** Closes the menu; with `refocus`, gives the focus back to the pill. */
  const hide = (refocus: boolean) => {
    setOpen(false);
    if (refocus) pill.current?.focus();
  };
  const pick = (next: number, refocus: boolean) => {
    const option = options[next];
    hide(refocus);
    if (!option || option.value === chosen) return;
    if (value === undefined) setOwn(option.value);
    onChange?.(option.value);
    setMessage(`${label}: ${option.label}`);
  };

  // A press anywhere outside the picker closes the menu; so does the focus leaving it. A picker
  // drawn inert (a locked specimen) can't be used, so nothing closes it.
  useEffect(() => {
    if (!open || picker.current?.closest('[inert]')) return;
    const away = (event: PointerEvent) => {
      if (!picker.current?.contains(event.target as Node)) setOpen(false);
    };
    document.addEventListener('pointerdown', away, true);
    return () => document.removeEventListener('pointerdown', away, true);
  }, [open]);

  const onPillKey = (event: ReactKeyboardEvent) => {
    if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
      event.preventDefault();
      show(true);
    } else if (event.key === 'Escape' && open) {
      event.preventDefault();
      hide(false);
    } else if (event.key === ' ' && !event.repeat) setPressed(true);
  };

  const onOptionKey = (event: ReactKeyboardEvent, at: number) => {
    const last = options.length - 1;
    const move = (to: number) => {
      event.preventDefault();
      items.current[to]?.focus();
    };
    if (event.key === 'ArrowRight' || event.key === 'ArrowDown') move(at === last ? 0 : at + 1);
    else if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') move(at === 0 ? last : at - 1);
    else if (event.key === 'Home') move(0);
    else if (event.key === 'End') move(last);
    else if (event.key === 'Escape') {
      event.preventDefault();
      hide(true);
    } else if (event.key === 'Tab') setOpen(false);
  };

  // The demo: the pointer presses the pill, the menu opens, the pointer picks the other choice.
  const demoStep = useEffectEvent(
    (step: 'hover' | 'press' | 'open' | 'choose' | 'pick' | 'off') => {
      if (step === 'hover') setDemoHover('pill');
      else if (step === 'press') setPressed(true);
      else if (step === 'open') {
        setPressed(false);
        setDemoHover(null);
        setMarked(index);
        setOpen(true);
      } else if (step === 'choose') setDemoHover(index === 0 ? 1 : 0);
      else if (step === 'pick') {
        setDemoHover(null);
        const next = index === 0 ? 1 : 0;
        setOpen(false);
        if (value === undefined) setOwn(options[next]?.value);
      } else {
        setDemoHover(null);
        setPressed(false);
        setOpen(false);
      }
    },
  );
  const target = useEffectEvent(() => (index === 0 ? 1 : 0));
  useEffect(() => {
    const area = stage.current;
    const arrow = pointer.current;
    const button = pill.current;
    if (!demo || !area || !arrow || !button) return;
    /** Where an element is, in the stage's own px: a point at shares of its box. */
    const at = (element: Element, x: number, y: number): Point => {
      const frame = area.getBoundingClientRect();
      const scale = frame.width / area.offsetWidth || 1;
      const rect = element.getBoundingClientRect();
      return {
        x: (rect.left + rect.width * x - frame.left) / scale,
        y: (rect.top + rect.height * y - frame.top) / scale,
      };
    };
    const away = (): Point => {
      const point = at(button, 0.5, 0.5);
      return { x: point.x + area.offsetWidth * 0.22, y: point.y + area.offsetHeight * 0.2 };
    };
    const stop = runDemo(area, arrow, async ({ wait, glide }) => {
      await glide(away(), 10);
      await wait(500);
      for (;;) {
        await glide(at(button, 0.56, 0.72), DEMO.toPill);
        demoStep('hover');
        await wait(DEMO.hover);
        demoStep('press');
        await wait(DEMO.press);
        demoStep('open');
        await wait(DEMO.settle);
        const choice = items.current[target()];
        if (!choice) return;
        await glide(at(choice, 0.6, 0.7), DEMO.toChoice);
        demoStep('choose');
        await wait(DEMO.dwell);
        demoStep('pick');
        await glide(away(), DEMO.away);
        await wait(DEMO.rest);
      }
    });
    return () => {
      stop();
      demoStep('off');
    };
  }, [demo, stage]);

  const control = (
    <span
      ref={picker}
      className={styles.picker}
      data-part="picker"
      data-open={open || undefined}
      onBlur={(event) => {
        if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setOpen(false);
      }}
    >
      <span
        id={menuId}
        role="listbox"
        aria-label={label}
        aria-orientation="horizontal"
        className={styles.menu}
        data-part="menu"
        inert={!open}
      >
        {options.map((option, at) => (
          <button
            key={option.value}
            ref={(element) => {
              items.current[at] = element;
            }}
            type="button"
            role="option"
            aria-selected={at === marked}
            tabIndex={at === marked ? 0 : -1}
            className={styles.option}
            data-part="option"
            data-selected={at === marked || undefined}
            data-hover={demoHover === at || undefined}
            onClick={() => pick(at, true)}
            onKeyDown={(event) => onOptionKey(event, at)}
          >
            <span className={styles.optionIcon} aria-hidden="true">
              {option.icon}
            </span>
            <span className={styles.optionLabel}>{option.label}</span>
          </button>
        ))}
        <span className={styles.tail} data-part="tail" aria-hidden="true" />
      </span>
      <button
        ref={pill}
        type="button"
        className={styles.pill}
        data-part="pill"
        data-pressed={pressed || undefined}
        data-hover={demoHover === 'pill' || undefined}
        aria-haspopup="listbox"
        aria-expanded={open}
        aria-controls={menuId}
        aria-label={`${label}: ${current?.label ?? ''}`}
        onClick={() => (open ? hide(false) : show(false))}
        onPointerDown={(event) => {
          if (event.button === 0) setPressed(true);
        }}
        onPointerUp={() => setPressed(false)}
        onPointerLeave={() => {
          setPressed(false);
          if (demoHover === 'pill' && !demo) setDemoHover(null);
        }}
        onPointerCancel={() => setPressed(false)}
        onKeyDown={onPillKey}
        onKeyUp={() => setPressed(false)}
      >
        <span className={styles.icons} data-part="pill-icon" aria-hidden="true">
          {options.map((option, at) => (
            <span
              key={option.value}
              className={styles.icon}
              data-current={at === index || undefined}
            >
              {option.icon}
            </span>
          ))}
        </span>
        <RollingLabel text={current?.label ?? ''} />
        <span className={styles.chevron} data-part="chevron" aria-hidden="true">
          <ChevronIcon />
        </span>
      </button>
    </span>
  );

  const live = (
    <span className={styles.hidden} role="status">
      {message}
    </span>
  );

  if (inline)
    return (
      <span className={`${styles.inline} ${className}`} data-color-scheme={colorScheme}>
        {control}
        {live}
      </span>
    );

  return (
    <div
      ref={stage}
      className={`${styles.root} ${className}`}
      data-part="stage"
      data-color-scheme={colorScheme}
      onPointerDownCapture={take}
      onKeyDownCapture={take}
      onPointerMove={(event) => {
        // The reader's own pointer in the stage takes over from the demo's.
        if (event.pointerType !== 'touch') take();
      }}
    >
      <div className={styles.scene}>{control}</div>
      <div className={styles.pointerLayer} aria-hidden="true">
        <DemoPointer ref={pointer} className={styles.pointer} />
      </div>
      {live}
    </div>
  );
}
```

### visibility-picker/visibility-picker.module.css

```css
/* 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;
  --pill-icon: #afafaf;
  /* The menu's frame and tail, and its choices on it. */
  --shell: #efefef;
  --segment: #fcfcfc;
  /* The chosen choice's words and icon; the others'. */
  --strong: #000;
  --option-ink: #5f5f5f;
  --option-icon: #a5a5a5;
  /* The same on both schemes: the focus ring, and the demo's pointer, which looks like the
     system's. */
  --focus: #387bff;
  --pointer: #111;
  --pointer-edge: #fff;
  --pointer-shadow: rgb(0 0 0 / 0.25);
  /* The curves. */
  --spring: linear(
    0,
    0.374 8.3%,
    0.609 16.7%,
    0.756 25%,
    0.848 33.3%,
    0.906 41.7%,
    0.943 50%,
    0.965 58.3%,
    0.98 66.7%,
    0.989 75%,
    0.994 83.3%,
    0.998 91.7%,
    1
  );
  --rise: linear(
    0,
    0.168 10%,
    0.323 20%,
    0.464 30%,
    0.591 40%,
    0.703 50%,
    0.799 60%,
    0.878 70%,
    0.94 80%,
    0.982 90%,
    1
  );
  --clear: linear(0, 0.5 31%, 0.71 50%, 0.81 62.5%, 0.936 81%, 1);
  --sink: linear(
    0,
    0.15 5.9%,
    0.31 17.6%,
    0.47 29.4%,
    0.59 41.2%,
    0.67 47%,
    0.75 58.8%,
    0.82 70.6%,
    0.92 82.4%,
    1
  );
}
@supports (color: light-dark(#fff, #000)) {
  .root,
  .inline {
    --surface: light-dark(#fefefe, #111113);
    --pill: light-dark(#f5f5f5, #1e1e21);
    --pill-hover: light-dark(#f1f1f1, #232326);
    --pill-press: light-dark(#f0f0f0, #252528);
    --pill-open: light-dark(#e5e5e5, #2e2e32);
    --pill-ink: light-dark(#525252, #b9b9bf);
    --pill-icon: light-dark(#afafaf, #6c6c73);
    --shell: light-dark(#efefef, #222225);
    --segment: light-dark(#fcfcfc, #17171a);
    --strong: light-dark(#000, #fafafa);
    --option-ink: light-dark(#5f5f5f, #9a9aa1);
    --option-icon: light-dark(#a5a5a5, #5d5d64);
  }
}
/* The colorScheme prop. Without it, nothing is set: color-scheme is inherited from the page. */
.root[data-color-scheme='light'],
.inline[data-color-scheme='light'] {
  color-scheme: light;
}
.root[data-color-scheme='dark'],
.inline[data-color-scheme='dark'] {
  color-scheme: dark;
}
.root[data-color-scheme='system'],
.inline[data-color-scheme='system'] {
  color-scheme: light dark;
}

/* The lab's stage: the picker in its middle, drawn larger on a wider stage. */
.root {
  position: relative;
  container-type: inline-size;
  width: 100%;
  aspect-ratio: 16 / 9;
  min-height: 300px;
  overflow: hidden;
  overflow: clip;
  border-radius: 18px;
  background: var(--surface);
  -webkit-tap-highlight-color: transparent;
}
.scene {
  position: absolute;
  inset: 0;
  display: grid;
  place-items: center;
  font-size: clamp(12px, 2.7cqi, 28px);
}
.inline {
  display: inline-block;
  vertical-align: middle;
}

/* The picker: the pill, with the menu above it. */
.picker {
  position: relative;
  z-index: 0;
  display: inline-block;
  vertical-align: middle;
  font:
    600 1em/1 'Library Inter',
    Inter,
    ui-sans-serif,
    system-ui,
    -apple-system,
    'Segoe UI',
    Arial,
    sans-serif;
  letter-spacing: -0.065em;
  -webkit-font-smoothing: antialiased;
}
.picker[data-open] {
  z-index: 10;
}

/* The pill. */
.pill {
  position: relative;
  z-index: 1;
  display: inline-flex;
  align-items: center;
  gap: 0.396em;
  box-sizing: border-box;
  height: 3.146em;
  margin: 0;
  padding: 0 1em;
  border: 0;
  border-radius: 999px;
  background: var(--pill);
  color: var(--pill-ink);
  font: inherit;
  letter-spacing: inherit;
  white-space: nowrap;
  cursor: pointer;
  user-select: none;
  touch-action: manipulation;
  will-change: transform;
  /* Let go: it springs back. */
  transition:
    transform 420ms var(--spring),
    background-color 270ms linear;
}
@media (hover: hover) {
  .pill:hover {
    background: var(--pill-hover);
    transition:
      transform 420ms var(--spring),
      background-color 100ms ease-out;
  }
}
.pill[data-hover] {
  background: var(--pill-hover);
  transition:
    transform 420ms var(--spring),
    background-color 100ms ease-out;
}
/* Held down: it sinks. */
.pill[data-pressed] {
  background: var(--pill-press);
  transform: scale(0.82);
  transition:
    transform 185ms ease-out,
    background-color 100ms ease-out;
}
.picker[data-open] .pill {
  background: var(--pill-open);
  transition:
    transform 420ms var(--spring),
    background-color 280ms linear;
}
.picker[data-open] .pill[data-pressed] {
  transition:
    transform 185ms ease-out,
    background-color 100ms ease-out;
}
.pill:focus {
  outline: none;
}
.pill:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 3px;
}

/* The pill's icons, one per choice, in one cell: the chosen one shows, the others fade out. */
.icons {
  display: grid;
  flex-shrink: 0;
  width: 1em;
  height: 1em;
  color: var(--pill-icon);
}
.icon {
  grid-area: 1 / 1;
  display: block;
  opacity: 0;
  filter: blur(0.15em);
  transition:
    opacity 110ms linear,
    filter 110ms linear;
}
.icon[data-current] {
  opacity: 1;
  filter: none;
  transition:
    opacity 160ms ease-out 20ms,
    filter 180ms ease-out 20ms;
}
.icon svg,
.optionIcon svg,
.chevron svg {
  display: block;
  width: 1em;
  height: 1em;
  overflow: visible;
}
/* The words sit a touch low, on the icons' middle. */
.label,
.optionLabel {
  position: relative;
  top: 0.03em;
}

/* The words: the new word in the flow, the old one over it until its letters have rolled out. */
.label {
  display: inline-block;
  white-space: nowrap;
}
.word {
  display: inline-block;
}
.before {
  position: absolute;
  top: 0;
  left: 0;
}
.letter {
  display: inline-block;
  white-space: pre;
}
.letter[data-roll='kept'] {
  visibility: hidden;
}
.letter[data-roll='in'] {
  animation: roll-in 180ms cubic-bezier(0.3, 0.6, 0.4, 1) both;
  animation-delay: calc(var(--step) * 32ms + 10ms);
}
.letter[data-roll='out'] {
  animation: roll-out 150ms cubic-bezier(0.5, 0, 0.75, 0.6) both;
  animation-delay: calc(var(--step) * 32ms);
}
@keyframes roll-in {
  from {
    opacity: 0;
    transform: translateY(0.45em);
    filter: blur(0.06em);
  }
}
@keyframes roll-out {
  to {
    opacity: 0;
    transform: translateY(-0.45em);
    filter: blur(0.06em);
  }
}

/* The chevron turns over, clockwise, as the menu opens; back as it closes. */
.chevron {
  display: block;
  flex-shrink: 0;
  width: 1em;
  height: 1em;
  color: var(--pill-icon);
  transform: translateY(0.05em) rotate(0deg);
  transition: transform 210ms var(--rise);
}
.picker[data-open] .chevron {
  transform: translateY(0.05em) rotate(180deg);
  transition: transform 240ms var(--rise);
}

/* The menu: the choices on a frame with a tail, centred above the pill. Closed, it has sunk
   1.6em, blurred and faded; it rises out of the blur, from behind the pill. */
.menu {
  position: absolute;
  bottom: calc(100% + 0.667em);
  left: 50%;
  z-index: 0;
  display: flex;
  gap: 0.333em;
  padding: 0.333em;
  border-radius: 1.75em;
  background: var(--shell);
  white-space: nowrap;
  opacity: 0;
  visibility: hidden;
  transform: translate(-50%, 1.6em);
  filter: blur(1.3em);
  will-change: transform, opacity, filter;
  transition:
    opacity 210ms linear 45ms,
    transform 280ms var(--sink) 15ms,
    filter 240ms linear 15ms,
    visibility 0s linear 295ms;
}
.picker[data-open] .menu {
  opacity: 1;
  visibility: visible;
  transform: translate(-50%, 0);
  filter: none;
  transition:
    opacity 170ms linear,
    transform 340ms var(--rise),
    filter 270ms var(--clear),
    visibility 0s;
}

/* The tail: a square turned on its corner, its lower half below the frame, its tip rounded. */
.tail {
  position: absolute;
  top: calc(100% - 0.331em);
  left: calc(50% - 0.331em);
  z-index: 0;
  width: 0.663em;
  height: 0.663em;
  border-bottom-right-radius: 0.25em;
  background: var(--shell);
  transform: rotate(45deg);
}

/* A choice. The outer ends of the row are round; where two choices meet, the corners are small. */
.option {
  position: relative;
  z-index: 1;
  display: inline-flex;
  align-items: center;
  gap: 0.325em;
  box-sizing: border-box;
  height: 2.833em;
  margin: 0;
  padding: 0 1.117em 0 0.854em;
  border: 0;
  border-radius: 0.333em;
  background: var(--segment);
  color: var(--option-ink);
  font: inherit;
  letter-spacing: inherit;
  cursor: pointer;
  user-select: none;
  touch-action: manipulation;
}
.option:first-of-type {
  border-top-left-radius: 1.417em;
  border-bottom-left-radius: 1.417em;
}
.option:last-of-type {
  border-top-right-radius: 1.417em;
  border-bottom-right-radius: 1.417em;
}
.optionIcon {
  display: block;
  flex-shrink: 0;
  width: 1em;
  height: 1em;
  color: var(--option-icon);
}
.optionIcon,
.optionLabel {
  transition: opacity 150ms ease-out;
}
.option[data-selected] {
  color: var(--strong);
}
.option[data-selected] .optionIcon {
  color: var(--strong);
}
/* Under the pointer, a choice not chosen fades a little. */
@media (hover: hover) {
  .option:not([data-selected]):hover .optionIcon,
  .option:not([data-selected]):hover .optionLabel {
    opacity: 0.84;
  }
}
.option:not([data-selected])[data-hover] .optionIcon,
.option:not([data-selected])[data-hover] .optionLabel {
  opacity: 0.84;
}
.option:focus {
  outline: none;
}
.option:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: -2px;
}

/* The demo pointer, over everything. */
.pointerLayer {
  position: absolute;
  inset: 0;
  pointer-events: none;
}
.pointer {
  width: clamp(18px, 3.9cqi, 40px);
  height: clamp(18px, 3.9cqi, 40px);
}

.hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

@media (prefers-reduced-motion: reduce) {
  .pill,
  .pill[data-pressed],
  .icon,
  .icon[data-current],
  .chevron,
  .picker[data-open] .chevron,
  .menu,
  .picker[data-open] .menu,
  .optionIcon,
  .optionLabel {
    transition: none;
  }
  .pill[data-pressed] {
    transform: none;
  }
  .menu {
    filter: none;
  }
  .letter[data-roll] {
    animation: none;
  }
  .before {
    display: none;
  }
}
```

### visibility-picker/rolling-label.tsx

```tsx
// 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;

export function RollingLabel({ text }: { text: string }) {
  const [words, setWords] = useState({ now: text, before: '', turn: 0 });
  const box = useRef<HTMLSpanElement>(null);
  const word = useRef<HTMLSpanElement>(null);
  const width = useRef(0);

  // The word's width, kept up to date (the font may load late): where the next change starts.
  useLayoutEffect(() => {
    const element = word.current;
    if (!element) return;
    const observer = new ResizeObserver(() => {
      width.current = widthOf(element);
    });
    observer.observe(element);
    return () => observer.disconnect();
  }, [words.turn]);

  if (words.now !== text) setWords({ now: text, before: words.now, turn: words.turn + 1 });

  // The box eases from the old word's width to the new word's.
  useLayoutEffect(() => {
    const element = box.current;
    const next = word.current ? widthOf(word.current) : 0;
    const previous = width.current;
    width.current = next;
    if (!element || !previous || previous === next || !words.before) return;
    if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
    const easing = element.animate([{ width: `${previous}px` }, { width: `${next}px` }], {
      duration: 250,
      delay: 30,
      easing: 'cubic-bezier(0.25, 0.1, 0.5, 1)',
      fill: 'backwards',
    });
    return () => easing.cancel();
  }, [words]);

  // The old word goes once its letters have.
  useLayoutEffect(() => {
    if (!words.before) return;
    const timer = window.setTimeout(
      () => setWords((now) => ({ ...now, before: '' })),
      LINGER + words.before.length * 32,
    );
    return () => window.clearTimeout(timer);
  }, [words]);

  // How many letters the two words share from their start.
  let same = 0;
  while (same < words.now.length && words.now[same] === words.before[same]) same += 1;
  const rolling = Boolean(words.before);

  return (
    <span ref={box} className={styles.label} data-part="label">
      <span ref={word} key={`now-${words.turn}`} className={styles.word}>
        {[...words.now].map((letter, index) => (
          <span
            key={index}
            className={styles.letter}
            data-roll={rolling && index >= same ? 'in' : undefined}
            style={{ '--step': index - same } as CSSProperties}
          >
            {letter}
          </span>
        ))}
      </span>
      {rolling && (
        <span key={`before-${words.turn}`} className={`${styles.word} ${styles.before}`}>
          {[...words.before].map((letter, index) => (
            <span
              key={index}
              className={styles.letter}
              data-roll={index >= same ? 'out' : 'kept'}
              style={{ '--step': index - same } as CSSProperties}
            >
              {letter}
            </span>
          ))}
        </span>
      )}
    </span>
  );
}
```

### visibility-picker/icons.tsx

```tsx
// 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. */
export function GlobeIcon({ className }: { className?: string }) {
  return (
    <svg className={className} viewBox="0 0 48 48" aria-hidden="true" focusable="false">
      <g fill="none" stroke="currentColor" strokeWidth="4.2">
        <circle cx="24" cy="24" r="21.6" strokeWidth="4.6" />
        <ellipse cx="24" cy="24" rx="9.4" ry="21.6" />
        <path d="M24 2.4V45.6M2.4 24H45.6M5.2 14.4H42.8M5.2 33.6H42.8" />
      </g>
    </svg>
  );
}

/** The pill's chevron, pointing down; the stylesheet turns it up while the menu is open. */
export function ChevronIcon({ className }: { className?: string }) {
  return (
    <svg className={className} viewBox="0 0 48 48" aria-hidden="true" focusable="false">
      <path
        d="M7.5 16L24 32L40.5 16"
        fill="none"
        stroke="currentColor"
        strokeWidth="5.6"
        strokeLinecap="round"
        strokeLinejoin="round"
      />
    </svg>
  );
}
```

### visibility-picker/demo-pointer.tsx

```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.
 */

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>;
  /** Glides the pointer to `to`, in the stage's own px, in `ms`; `along` gets the flight. */
  glide: (to: Point, ms: number, along?: (flight: Flight) => void) => Promise<void>;
};

/** What a stopped demo's pending step rejects with. */
class Stopped extends Error {}

/** Where the arrow's tip is, as shares of its box: the point the pointer stands on. */
const TIP: Point = { x: 0.15, y: 0.1 };

/** How many points a flight is drawn through. */
const SAMPLES = 24;

/** Plays `script` with the pointer over `stage`; returns the function that stops it. */
export function runDemo(
  stage: HTMLElement,
  pointer: HTMLElement,
  script: (demo: Demo) => Promise<void>,
): () => void {
  let alive = true;
  let flight: Animation | null = null;
  // The stylesheet sizes the pointer from the stage's width; its tip is where it stands.
  const tip = { x: pointer.offsetWidth * TIP.x, y: pointer.offsetHeight * TIP.y };
  const place = ({ x, y }: Point) => `translate(${x - tip.x}px, ${y - tip.y}px)`;
  const timers = new Set<number>();
  const pending = new Set<() => void>();
  // The pointer comes in from the lower right.
  let at: Point = { x: stage.offsetWidth * 0.8, y: stage.offsetHeight * 0.9 };

  /** Settles like `promise`, or rejects as soon as the demo stops. */
  const step = (promise: Promise<unknown>) =>
    new Promise<void>((resolve, reject) => {
      if (!alive) return reject(new Stopped());
      const cancel = () => reject(new Stopped());
      pending.add(cancel);
      promise.then(
        () => {
          pending.delete(cancel);
          if (alive) resolve();
        },
        () => {
          pending.delete(cancel);
          reject(new Stopped());
        },
      );
    });

  const wait = (ms: number) =>
    step(
      new Promise<void>((resolve) => {
        const timer = window.setTimeout(() => {
          timers.delete(timer);
          resolve();
        }, ms);
        timers.add(timer);
      }),
    );

  const glide: Demo['glide'] = (to, ms, along) => {
    const from = at;
    at = to;
    const dx = to.x - from.x;
    const dy = to.y - from.y;
    // A gentle curve, as a hand moves: the control point sits off the straight line.
    const bend = { x: (from.x + to.x) / 2 - dy * 0.18, y: (from.y + to.y) / 2 + dx * 0.18 };
    const points: Point[] = [];
    for (let i = 0; i <= SAMPLES; i += 1) {
      const t = i / SAMPLES;
      const u = 1 - t;
      points.push({
        x: u * u * from.x + 2 * u * t * bend.x + t * t * to.x,
        y: u * u * from.y + 2 * u * t * bend.y + t * t * to.y,
      });
    }
    const timing: KeyframeAnimationOptions = {
      duration: ms,
      easing: 'cubic-bezier(0.45, 0, 0.25, 1)',
    };
    flight?.cancel();
    // Where it rests once the flight is over.
    pointer.style.transform = place(to);
    flight = pointer.animate(
      points.map((point) => ({ transform: place(point) })),
      timing,
    );
    along?.({ points, timing });
    return step(flight.finished);
  };

  pointer.style.transform = place(at);
  pointer.style.opacity = '1';
  script({ wait, glide }).catch((error: unknown) => {
    if (!(error instanceof Stopped)) throw error;
  });

  return () => {
    if (!alive) return;
    alive = false;
    timers.forEach((timer) => window.clearTimeout(timer));
    flight?.cancel();
    pending.forEach((cancel) => cancel());
    pointer.style.opacity = '0';
  };
}

/**
 * The pointer's box: it moves, on a layer of its own. Its size comes from its class, its colours
 * from the stage's tokens (visibility-picker.module.css).
 */
const POINTER: CSSProperties = {
  position: 'absolute',
  top: 0,
  left: 0,
  zIndex: 2,
  display: 'block',
  pointerEvents: 'none',
  opacity: 0,
  transition: 'opacity 0.3s ease',
  filter: 'drop-shadow(0 1px 1.5px var(--pointer-shadow))',
  willChange: 'transform',
};

const DRAWING: CSSProperties = {
  display: 'block',
  width: '100%',
  height: '100%',
  overflow: 'visible',
};
/** The arrow is dark on a white edge, as the system draws it. */
const ARROW: CSSProperties = { fill: 'var(--pointer)', stroke: 'var(--pointer-edge)' };

/**
 * The pointer the demo moves around: the system's arrow. It is decorative: what it does shows on
 * the picker itself.
 */
export function DemoPointer({ ref, className }: { ref: Ref<HTMLSpanElement>; className?: string }) {
  return (
    <span ref={ref} className={className} style={POINTER} aria-hidden="true">
      <svg viewBox="0 0 20 20" style={DRAWING}>
        <path
          d="M3 2v14.5l3.6-3.3 2.6 5.6 2.4-1.1-2.6-5.5h5.2z"
          style={ARROW}
          strokeWidth="1.3"
          strokeLinejoin="round"
        />
      </svg>
    </span>
  );
}
```

### visibility-picker/use-autoplay.ts

```tsx
// 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(
      ([entry]) => {
        onScreen = entry.isIntersecting;
        update();
      },
      { threshold: 0.3 },
    );
    observer.observe(element);
    document.addEventListener('visibilitychange', update);
    const motion = window.matchMedia('(prefers-reduced-motion: reduce)');
    const readMotion = () => setReduced(motion.matches);
    readMotion();
    motion.addEventListener('change', readMotion);
    return () => {
      observer.disconnect();
      document.removeEventListener('visibilitychange', update);
      motion.removeEventListener('change', readMotion);
    };
  }, []);

  const take = useCallback(() => {
    setTaken(true);
    window.clearTimeout(timer.current);
    timer.current = window.setTimeout(() => setTaken(false), idle);
  }, [idle]);
  useEffect(() => () => window.clearTimeout(timer.current), []);

  return { ref, take, playing: visible && !reduced && !taken };
}
```