Picker
Labeled setting row that opens a check-marked option list — the compact control used by theme configurators, settings rails and editor strips.
Playground
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).
When not to use a Picker
- Searching or filtering a long list →
Combobox. A Picker's list is known and short; past ~15 options you want typing. - A form field with helper/error text, label layouts or multi-select →
Select. Picker'sintenttints the boundary only, on purpose. - Commands and actions →
DropdownMenu. Options describe state; menus do things (HIG's pop-up vs pull-down split). - Time/date wheels →
TimePicker/ native inputs.
Selection applies instantly and the popup closes on choose — a Picker is
never behind an Apply button. Give it a sensible defaultValue: the
placeholder is a hint, never the label. 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
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 (macOS) verified locally; NVDA and JAWS are UNTESTED on this project's hardware.
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…" | 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" | "ghost" | "outline" | Bordered well vs 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…" | Row shown while loading |
emptyText | ReactNode | "No options" | Row shown when there are no options |
dir | "ltr" | "rtl" | inherit | Stamped only when set — otherwise the ambient direction applies |
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.