Select
A button-triggered select whose value crossfades on change, with an anchored list that flips at edges, groups and separators, typeahead and arrow-key navigation, and a sliding highlight.
A button-triggered select that takes its options as a data array. When the value changes, the trigger text crossfades; the list is portaled and anchored next to the trigger, flips to the opposite side and shifts along the edge when space runs out, and shows scroll hints when it gets long. It supports groups and separators, disabled options, typeahead, and arrow / Home / End navigation, with a highlight background that slides between options. Both the value and the open state can be controlled or uncontrolled, and passing name makes it submit with a native form.
Bring this into your project
Run this command in your project folder. The CLI adds the source and dependencies; then follow the usage example below.
npx webberui@latest add select.enPlayground
Tune the props live — the code snippet updates as you go, so you can dial in the look you want before copying it.
<Select/>
The complete example includes the preview layout and data. Install the component first, then paste this file into your project.
Installation
npx webberui@latest add https://webberui.com/r/select.en.jsonOr, once registries are configured in components.json, install it as @webberui/select.
Usage
import { Select } from "@/components/ui/select";
const [language, setLanguage] = React.useState("en");
<Select
aria-label="Interface language"
value={language}
onValueChange={setLanguage}
options={[
{ value: "zh-TW", label: "繁體中文(台灣)" },
{ value: "en", label: "English" },
{ value: "ja", label: "日本語" },
]}
/>
// Grouped: a separator is inserted between groups automatically
<Select
aria-label="Time zone"
name="timezone"
placeholder="Choose a time zone"
groups={[
{ label: "Asia", options: [{ value: "Asia/Taipei", label: "Taipei (UTC+8)" }] },
{ label: "Europe", options: [{ value: "Europe/London", label: "London (UTC+0)" }] },
]}
/>Props
| Prop | Type | Default | Description |
|---|---|---|---|
options | SelectOption[] | — | Flat option list; use this or groups — groups wins when both are given |
groups | SelectGroup[] | — | Grouped options; a separator is inserted between groups automatically |
value | string | — | Controlled value; when omitted the component manages it internally (uncontrolled) |
defaultValue | string | — | Initial value in uncontrolled mode |
onValueChange | (value: string) => void | — | Called when the selected value changes |
open | boolean | — | Controlled open state |
defaultOpen | boolean | false | Initial open state in uncontrolled mode |
onOpenChange | (open: boolean) => void | — | Called when the open state changes |
placeholder | string | "Select an option" | Placeholder text shown when nothing is selected (the Chinese registry version defaults to "請選擇", "please choose") |
disabled | boolean | false | Disable the whole select |
name | string | — | Form field name; when set, a hidden input is rendered so native form submission includes the current value |
size | "sm" | "md" | "md" | Trigger size |
duration | number | 100 | Enter/exit duration of the content (ms); also written to --wb-select-duration for CSS transitions |
side | "top" | "right" | "bottom" | "left" | "bottom" | Preferred side for the content; flips to the opposite side when there is not enough room |
align | "start" | "center" | "end" | "start" | Alignment of the content on the cross axis |
sideOffset | number | 4 | Distance between the content and the trigger (px) |
aria-label | string | — | Accessible name for the trigger and the list |
triggerClassName | string | — | Extra classes applied to the trigger |
className | string | — | Forwarded to the root element |
SelectOption
| Field | Type | Default | Description |
|---|---|---|---|
value | string | — | Option value (submitted with forms and passed to onValueChange) |
label | string | — | Display text; also what typeahead matches against |
icon | ReactNode | — | Icon shown before the text |
disabled | boolean | false | Disable this option: it cannot be selected and arrow keys / typeahead skip it |
group | string | — | Group name; only applies when using options — options with the same name are collected into one group |
SelectGroup
| Field | Type | Default | Description |
|---|---|---|---|
label | string | — | Group heading |
options | SelectOption[] | — | Options in the group |
How it works
- Positioning: the content is portaled to
bodywithposition: fixedand placed outside the trigger according toside/align/sideOffset. When the main axis overflows and the opposite side has more room it flips; on the cross axis it shifts back into the viewport (8px from the edge). It recomputes when the trigger or content resizes, the window resizes, or any ancestor scrolls, at most once per frame. The content carriesdata-side/data-alignso you can style by actual placement. - Sizing: the width is the larger of the trigger width and the content width; the height is capped at 18rem and at the available viewport space. Beyond that the list scrolls, scroll hints appear at the top / bottom, and hovering a hint keeps scrolling.
- Animation: on enter the content fades in, scales up from 0.95 and slides 8px in from against its side, with the transform origin set from the flipped, actual placement; closing reverses the same values.
durationis also written to the CSS variable--wb-select-duration, which CSS transitions such as the chevron rotation read, so you can override it globally. The trigger text crossfades at 1.5× the duration when the value changes. - Typeahead: typing within 500ms accumulates a prefix that matches the start of option labels; CJK labels match on the first character, and repeating one character cycles through options with that initial. Typing while the trigger is focused but closed changes the value directly, like a native select; keys pressed during IME composition are ignored.
- Passing
namerenders an<input type="hidden">, so a native form submission includes the current value.
Accessibility
- The trigger is
role="combobox"witharia-haspopup="listbox", and points to the list witharia-expanded/aria-controls; providearia-labelas its name - The list is
role="listbox", takes focus when opened and points to the highlighted option witharia-activedescendant; options arerole="option"witharia-selectedandaria-disabled; groups arerole="group"labelled by their heading viaaria-labelledby - Keyboard: Enter, Space, ↑ and ↓ open the list; ↑ / ↓ move the highlight (skipping disabled options, no wrapping), Home / End jump to the ends; Enter / Space select and close, Tab selects the highlighted option and moves on, Escape closes without changing the value; focus returns to the trigger after closing
- Clicking anywhere outside the content closes it
- When the user has enabled reduce motion, the content and the value change only fade, with no scaling or sliding, and the highlight jumps straight to its new position
NPS Survey Card
An NPS survey card for React: 0-10 segmented buttons coloured by detractor/passive/promoter band, a follow-up question that expands after scoring, a thank-you state, plus npsCategory and npsScore helpers.
Drawn Checkbox
A React checkbox whose check mark draws itself with a small squash-and-settle, morphing smoothly between the indeterminate dash and the check, with staggered select-all in groups.