Picker
StableA filterable list of options, on its own or in a popover: type to filter, arrows and Enter to choose, or 1–9 to pick at once.
Picker is a search field and a listbox (the APG combobox pattern): focus stays in the search while the highlight moves. It takes one value or many, can offer “Create …” when nothing matches, and with numberKeys the keys 1–9 choose the first nine options, as in Linear. PickerButton wires a trigger, a Popover and a Picker together.
PickerButton
import { useState } from 'react'import { Icon } from '@rchursin/product/icons/Icon'import { PickerButton, type PickerOption } from '@rchursin/product/ui'
const projects: PickerOption<string>[] = [ { value: 'platform', label: 'Platform', icon: 'layers' }, { value: 'agents', label: 'Agents', icon: 'agent' }, { value: 'skills', label: 'Skills', icon: 'skill', keywords: ['prompts'] }, { value: 'telemetry', label: 'Telemetry', icon: 'activity', keywords: ['otel', 'traces'] }, { value: 'docs', label: 'Docs', icon: 'file-text' }, { value: 'billing', label: 'Billing', icon: 'dollar', hint: 'No access', disabled: true },]
/** * The trigger, popover and filterable list in one. Open it, then press 1–5 to choose without * typing, or type to filter and press Enter. Esc closes and focus returns to the button. */export default function PickerProject() { const [project, setProject] = useState('platform') const current = projects.find((p) => p.value === project)!
return ( <PickerButton label="Project" numberKeys placeholder="Move to project…" options={projects} value={project} onChange={setProject} buttonClassName="lux-btn lux-btn-paper h-8 px-2.5 text-[13px] font-medium" buttonLabel={`Project: ${current.label}. Change project`} > {current.icon && <Icon name={current.icon} className="size-4 text-fg-2" />} {current.label} <Icon name="chevrons-up-down" className="size-3.5 text-fg-3" /> </PickerButton> )}Usage
import { Picker, PickerButton, type PickerOption, type PickerProps } from '@rchursin/product/ui'<PickerButton label="Project" numberKeys options={projects} value={project} onChange={setProject} buttonLabel={`Project: ${name}. Change project`}> {name}</PickerButton>Examples
Several values, and create
The Picker on its own with multiple and onCreate. Enter toggles and the list stays put; type a new word to create it.
import { useState } from 'react'import { Picker, type PickerOption } from '@rchursin/product/ui'
const starting: PickerOption<string>[] = [ { value: 'agent', label: 'agent' }, { value: 'bug', label: 'bug' }, { value: 'cost', label: 'cost' }, { value: 'docs', label: 'docs' }, { value: 'skill', label: 'skill' }, { value: 'telemetry', label: 'telemetry' },]
/** * The Picker on its own, for a side panel or your own popover. `multiple` keeps it open and * toggles with Enter; `onCreate` offers “Create …” when nothing matches what you typed. */export default function PickerLabels() { const [options, setOptions] = useState(starting) const [value, setValue] = useState<string[]>(['bug', 'skill'])
function create(text: string) { const id = text.toLowerCase().replace(/\s+/g, '-') if (!options.some((o) => o.value === id)) setOptions((all) => [...all, { value: id, label: id }]) setValue((v) => (v.includes(id) ? v : [...v, id])) }
return ( <div className="flex flex-col items-center gap-3"> <div className="lux-card overflow-hidden rounded-xl bg-raised"> <Picker label="Labels" multiple placeholder="Find or create a label…" options={options} value={value} onChange={setValue} onCreate={create} /> </div> <p className="text-xs text-fg-3">{value.length ? `${value.length} selected: ${value.join(', ')}` : 'No labels selected'}</p> </div> )}API
Picker props
| Prop | Type | Default | Description |
|---|---|---|---|
labelrequired | string | – | Accessible name of the list, e.g. "Status". The search is named “Status: search”. |
optionsrequired | PickerOption<V>[] | – | The choices. V is string or number. |
valuerequired | V | null · V[] with multiple | – | The current choice, or every chosen value when multiple is set. |
onChangerequired | (value: V) => void · (value: V[]) => void | – | Called with the new choice, or the new list of values. |
multiple | boolean | false | Several values: Enter and click toggle, options show a checkbox, and the list stays open. |
placeholder | string | 'Search…' | Say what the list does: “Change status…”. |
numberKeys | boolean | – | Shows 1–9 beside the first nine options; with the search empty, those keys choose them. |
onDone | () => void | – | Called after a single choice. PickerButton uses it to close its popover. |
onCreate | (text: string) => void | – | Adds “Create “text”” as the last option when no label matches the search exactly. |
emptyText | string | 'No matches' | Shown when nothing matches. |
className | string | – | Classes for the panel, which is 256 pixels wide by default. |
PickerButton props
| Prop | Type | Default | Description |
|---|---|---|---|
…Picker propsrequired | PickerProps<V> | – | Everything above; onDone is set for you. Every open starts fresh: the search is empty and the first option is highlighted. |
childrenrequired | ReactNode | – | The trigger’s contents, usually an icon and the current value. |
buttonLabel | string | – | The trigger’s accessible name, when its contents say too little: “Project: Platform. Change project”. |
buttonClassName | string | – | Classes for the trigger. It has no finish of its own: add lux-btn classes or your own. |
placement | Placement | 'bottom-start' | Where the popover opens. It flips when there is no room. |
PickerOption
| Prop | Type | Default | Description |
|---|---|---|---|
valuerequired | V extends string | number | – | What onChange receives. |
labelrequired | string | – | The option’s text; the search matches it. |
icon | IconName | – | An icon before the label. |
leading | ReactNode | – | A custom visual before the label, e.g. an avatar. Wins over icon. |
hint | string | – | Quiet text on the right. |
keywords | string[] | – | Extra words the search matches. |
disabled | boolean | – | Dimmed and marked aria-disabled; it can’t be chosen, though the arrow keys still pass over it. |
Accessibility
Keyboard
| Key | What it does |
|---|---|
| EnterSpace | On a PickerButton: opens the popover with focus in the search. |
| Down arrowUp arrow | Moves the highlight, wrapping at the ends. |
| HomeEnd | With the search empty: the first or the last option. |
| Enter | Chooses the highlighted option (toggles it with multiple), or creates what was typed. |
| 1–9 | With numberKeys and the search empty: chooses that option. |
| Esc | On a PickerButton: closes the popover and puts focus back on the trigger. A Picker on its own leaves Esc to whatever holds it. |
- The search has role="combobox" with aria-expanded, aria-controls and aria-activedescendant, named “<label>: search”. The list is a listbox named by label, with aria-multiselectable when multiple is set.
- Options carry aria-selected, and disabled ones aria-disabled. A check mark or a filled box shows what is chosen: shape, not colour.
- Clicking an option never takes focus away from the search, and the number hints are hidden from screen readers.
- When nothing matches, emptyText shows below the list, outside the listbox, in a polite live region, so screen readers announce it.
- PickerButton’s trigger has aria-haspopup="dialog" and aria-expanded; the popover is a dialog named by label. Focus moves into the search on open and back to the trigger on close.
Guidelines
Do
- Use numberKeys for short, fixed lists people choose from often: status, priority.
- Give the trigger a name that includes the current value.
- Add keywords for other words people type: initials, roles, synonyms.
Don’t
- Don’t use a Picker for navigation; it chooses values. Use a Menu for actions.
- Don’t turn on number keys for lists that change order.
In use
Related
- Property pickersThe Tasks tool’s pickers for status, priority, assignee, labels and estimate, as a chip, a row or an icon.
- ComboboxA text field that suggests options as you type, for long lists like time zones or skills (APG list autocomplete).
- PopoverA panel beside its trigger, on the native popover API: never clipped, closed by Esc or a click outside, and it hands focus back.
- MenuA menu button with actions, checkbox and radio items, group labels, shortcut hints and type-ahead, in a popover beside its trigger.
Source
The component’s own files, exactly as they run on this site.
/** * Picker: a filterable list of options (combobox + listbox, WAI-ARIA APG). * Focus stays in the search field; ↑ ↓ move the highlight (aria-activedescendant), * Enter chooses, Esc closes. With the field empty, the number keys 1–9 choose the nth option * (as Linear does for status and priority). * * Used for status, priority, assignee, labels, tags and calendars. Put it inside a <Popover>, * or use <PickerButton> which wires the trigger, popover and picker together. */import { useEffect, useId, useMemo, useRef, useState, type KeyboardEvent, type ReactNode,} from "react"import { cx } from "../../components/cx"import { Icon, type IconName } from "../icons/Icon"