Picker
Labeled setting row that opens a check-marked option list — the compact control used by theme configurators, settings rails and editor strips.
pnpm add @tessinaui/uiWhen to use
- One choice from a short, known list that fits a settings row: style, base colour, font, density.
- Compact setting rows in a configurator, settings rail or editor strip, where a full form field with its own label and helper text would be too heavy.
- A value that applies instantly. Selection applies and the popup closes on choose; a Picker is never behind an Apply button.
- Options that benefit from a preview or a hint: a swatch or glyph
(
adornment), a second line (description) or a trailing note (trailingText). - A sensible
defaultValue. The placeholder is a hint, never the label.
When not to use
- Searching or filtering a long list. Use Combobox. A Picker's list is known and short; past ~15 options you want typing.
- A form field with helper or error text, label layouts or multi-select. Use
Select. Picker's
intenttints the boundary only, on purpose. - Commands and actions. Use DropdownMenu. Options describe state; menus do things: a pop-up reports a value, a pull-down runs a command.
- Time or date wheels. Use TimePicker or native inputs.
- Two or three options that should all stay visible. Use Radio or SegmentedControl.
Installation
pnpm add @tessinaui/uiUsage
import { Picker, PickerSkeleton } from "@tessinaui/ui";{/* Basic — the trailing chevron is the default glyph */}
<Picker
label="Style"
options={[
{ value: "vega", label: "Vega" },
{ value: "nova", label: "Nova" },
{ value: "maia", label: "Maia" },
]}
defaultValue="nova"
/>
{/* Controlled, with per-option adornments (the trigger falls back to the
selected option's adornment when no trigger adornment is given) */}
<Picker
label="Base Color"
options={[
{ value: "violet", label: "Violet", adornment: <span className="size-3.5 rounded-full" style={{ background: "var(--chart-1)" }} /> },
{ value: "blue", label: "Blue", adornment: <span className="size-3.5 rounded-full" style={{ background: "var(--chart-2)" }} /> },
]}
value={color}
onValueChange={setColor}
/>
{/* Loading */}
<PickerSkeleton />Stack pickers full-width in a sidebar on desktop, or give each a fixed width
(e.g. w-36 shrink-0) inside a horizontally scrolling strip on mobile —
setting rows stack flush (the row height is the rhythm).
Provide option adornments for all options in a group or none of them, and order short lists semantically (most-used first) rather than alphabetically.
Examples
Default
Variants
Sizes
Rounded
Adornments
Option descriptions & badges
Grouped options
States & intents
On color
Loading & empty
Controlled
Self-previewing values
Popup footer
Mobile strip
Paired pickers
In a form
RTL
dir="rtl" mirrors the trigger (caption and value on the right, chevron on the left). align="start" then means the trigger's right edge, because the positioner receives dir too. Leave dir unset and the picker inherits the page's direction.
API Reference
Picker props
| Prop | Type | Default | Description |
|---|---|---|---|
label | ReactNode | — | Small muted caption above the value (e.g. "Style") |
options | PickerEntry[] | — | Options, { type: "group", label, options } groups and { type: "separator" } rows |
value | string | null | — | Controlled selected value; null shows the placeholder |
defaultValue | string | — | Uncontrolled initial value |
onValueChange | (value: string, details) => void | — | Called on selection; details is Base UI's event details |
adornment | ReactNode | null | chevron | Trailing glyph; falls back to the selected option's adornment, then a rotating chevron. null removes it |
placeholder | ReactNode | "Select…", Arabic under dir="rtl" | Value text when nothing is selected |
size | "sm" | "md" | "md" | Row padding; value text reads the input-family ladder (16px on phones) |
rounded | "none" | "sm" | "md" | "lg" | "xl" | "full" | "md" | §2 canonical scale; full caps at 24px (panel rule) |
variant | "outline" | "filled" | "ghost" | "outline" | Bordered well, a tinted well with no outline until focus or an intent, or the borderless settings-rail row |
intent | "none" | "error" | "warning" | "success" | "info" | "none" | Boundary + ring tint; error adds aria-invalid/data-invalid. No text slot — compose helper text via Field/Form |
tone | "default" | "on-color" | from Surface | Colored-plate rendering; resolves automatically inside <Surface> |
disabled | boolean | — | Removes the row from the tab order |
readOnly | boolean | — | Focusable but the value cannot change; full-contrast ink |
required | boolean | — | Caption star + sr-only text + aria-required |
name | string | — | Submits via the primitive's hidden input |
open / defaultOpen | boolean | — | Popup visibility (controlled / initial) |
onOpenChange | (open: boolean) => void | — | Popup visibility callback |
loading | boolean | — | List shows loadingText and announces busy |
loadingText | ReactNode | "Loading…", Arabic under dir="rtl" | Row shown while loading |
emptyText | ReactNode | "No options", Arabic under dir="rtl" | Row shown when there are no options |
dir | "ltr" | "rtl" | inherit | Stamped only when set — otherwise the ambient direction applies |
strings | Partial<PickerStrings> | English, Arabic under dir="rtl" | Override the required caption text ("(required)") and the placeholder, loading and empty rows. placeholder, loadingText and emptyText win |
side | "top" | "bottom" | "left" | "right" | "inline-start" | "inline-end" | "bottom" | Popup side; auto-flips when out of room |
align | "start" | "center" | "end" | "start" | Popup alignment along the side |
popupFooter | ReactNode | — | Content below the list, outside the listbox — buttons are legal ("Manage…", "Custom…") |
className | string | — | Extra classes on the trigger |
popupClassName | string | — | Extra classes on the popup |
positionerClassName | string | — | Raise the positioner's z-50 inside higher stacking contexts |
The trigger also accepts every native button attribute (id, data-*,
aria-*, event handlers) — it is the control, so everything lands on it.
PickerOption
| Field | Type | Description |
|---|---|---|
value | string | Unique option value |
label | ReactNode | Option text (also the trigger value when selected) |
textValue | string | Typeahead/announcement text for rich labels |
description | string | Second muted line; announced via aria-describedby |
trailingText | ReactNode | Trailing muted meta ("New", a price) — outside the name |
adornment | ReactNode | Leading visual in the list (e.g. a color swatch) |
disabled | boolean | Disables this option (description still explains why) |
PickerSkeleton props
| Prop | Type | Default | Description |
|---|---|---|---|
size | "sm" | "md" | "md" | Matches the trigger footprint |
rounded | "none" | "sm" | "md" | "lg" | "xl" | "full" | "md" | Border radius |
showAdornment | boolean | true | Render the trailing-glyph placeholder |
className | string | — | Extra classes |
Data attributes
data-component="picker" and data-slot on every part
(picker-trigger/label/value/adornment/popup/list/item/item-description/item-trailing/item-indicator/group/group-label/separator/footer/loading/empty/skeleton),
plus state marks: data-empty, data-invalid, data-variant, data-tone,
and the primitive's data-popup-open/data-disabled/data-selected/data-highlighted.
Accessibility
- The trigger is a
role="combobox"button named by the visible caption + value (aria-labelledby) — it announces as e.g. "Style Nova". Never add a duplicatearia-label. - The option list is a real
role="listbox"; the popup shell around it is presentational, which is what makespopupFootercontent legal. emptyText/loadingTextrender as disabled option rows (a listbox must never own zero options); the list isaria-busywhile loading.- Option
descriptionreaches AT viaaria-describedby— the option's name stays its label. Richlabelnodes needtextValuefor typeahead and announcements. requiredrenders a star plus screen-reader text andaria-required;intent="error"stampsaria-invalidanddata-invalid.- Full keyboard: Space/Enter/ArrowUp/ArrowDown open; arrows + Home/End move; Space/Enter select; Esc closes back to the trigger; typeahead works on the closed trigger and the open list.
- AT matrix: VoiceOver not yet run. Axe passes on the trigger at rest and on the open list (groups, separator, descriptions, footer, loading row) in both themes. NVDA and JAWS are untested (no Windows host).