Select
A native <select> with Input's field contract on top: the same four variants, the same eight roles, the same size ladder and the same label wrapper. What is ours is the closed control. The open list belongs to the platform — except where the customizable select hands it back, and then it takes the library's own menu surface.
- Stable
- React 19
- Native <select>
- Shares field.css with Input
Where you are billed.
Code
<Select label="Country"> <option value="ar">Argentina</option> <option value="cl">Chile</option> <option value="pe">Peru</option></Select><div class="wayra-label" data-placement="top" data-size="md"><label class="wayra-label-text" for="usage-country">Country</label><select class="wayra-select" id="usage-country"> <option value="ar">Argentina</option> <option value="cl">Chile</option> <option value="pe">Peru</option></select></div>children is required and holds plain <option> and <optgroup> elements. There is no
options array prop: grouping, disabled, selected and rich label attributes are all
things HTML already has, and an array would only re-describe them.
Everything Input documents applies here: the four variants, the eight roles, the label, helper and error contract and validation. The rest of this page is only the delta.
Playground
Section titled “Playground”Change the props and copy the code.
Where you are billed.
Generated code
<Select
placeholder="Choose a country"
label="Country"
helper="Where you are billed."
>
<option value="ar">Argentina</option>
<option value="cl">Chile</option>
<option value="pe">Peru</option>
</Select>Variants
Section titled “Variants”Code
<Select aria-label="outline">…</Select><Select variant="filled" aria-label="filled">…</Select><Select variant="soft" aria-label="soft">…</Select><Select variant="ghost" aria-label="ghost">…</Select><select class="wayra-select" data-variant="outline" aria-label="outline"> <option value="ar">Argentina</option> <option value="cl">Chile</option> <option value="pe">Peru</option></select><select class="wayra-select" data-variant="filled" aria-label="filled"> <option value="ar">Argentina</option> <option value="cl">Chile</option> <option value="pe">Peru</option></select><select class="wayra-select" data-variant="soft" aria-label="soft"> <option value="ar">Argentina</option> <option value="cl">Chile</option> <option value="pe">Peru</option></select><select class="wayra-select" data-variant="ghost" aria-label="ghost"> <option value="ar">Argentina</option> <option value="cl">Chile</option> <option value="pe">Peru</option></select>The arrow follows the variant. On outline, soft and ghost it is the muted hint colour;
on filled it switches to that role’s -content, where the muted grey would sink into the
fill.
Colors
Section titled “Colors”Four of the eight roles. See Input for all of them.
Code
<Select color="primary" aria-label="primary">…</Select><Select color="secondary" aria-label="secondary">…</Select><Select color="accent" aria-label="accent">…</Select><Select color="neutral" aria-label="neutral">…</Select><select class="wayra-select" data-color="primary" aria-label="primary"> <option value="ar">Argentina</option> <option value="cl">Chile</option> <option value="pe">Peru</option></select><select class="wayra-select" data-color="secondary" aria-label="secondary"> <option value="ar">Argentina</option> <option value="cl">Chile</option> <option value="pe">Peru</option></select><select class="wayra-select" data-color="accent" aria-label="accent"> <option value="ar">Argentina</option> <option value="cl">Chile</option> <option value="pe">Peru</option></select><select class="wayra-select" data-color="neutral" aria-label="neutral"> <option value="ar">Argentina</option> <option value="cl">Chile</option> <option value="pe">Peru</option></select>Code
<Select size="xs" aria-label="xs">…</Select><Select size="sm" aria-label="sm">…</Select><Select aria-label="md">…</Select><Select size="lg" aria-label="lg">…</Select><Select size="xl" aria-label="xl">…</Select><select class="wayra-select" data-size="xs" aria-label="xs"> <option value="ar">Argentina</option> <option value="cl">Chile</option> <option value="pe">Peru</option></select><select class="wayra-select" data-size="sm" aria-label="sm"> <option value="ar">Argentina</option> <option value="cl">Chile</option> <option value="pe">Peru</option></select><select class="wayra-select" data-size="md" aria-label="md"> <option value="ar">Argentina</option> <option value="cl">Chile</option> <option value="pe">Peru</option></select><select class="wayra-select" data-size="lg" aria-label="lg"> <option value="ar">Argentina</option> <option value="cl">Chile</option> <option value="pe">Peru</option></select><select class="wayra-select" data-size="xl" aria-label="xl"> <option value="ar">Argentina</option> <option value="cl">Chile</option> <option value="pe">Peru</option></select>Heights match Input and Button step for step, so a select sits in a form row next to either
without a magic number. The arrow is sized in em, so one rule covers all five steps. In RTL
it moves to the left edge: a dir="rtl" on the field or on any ancestor is enough, in every
supported engine, and :dir() corrects an LTR island inside RTL where the engine has it.
There is no width prop. A select is width: 100% like every other field, so it fills
the box it is in and never jumps width when its options change — which is what a native
select does, and the reason the browser’s intrinsic sizing is not used here. Narrower
means a narrower box; see Input › Width.
Placeholder
Section titled “Placeholder”placeholder prepends <option value="" disabled hidden> and pins the field to it.
Empty, then chosen. The prompt is dimmed; a real value is not.
Code
<Select placeholder="Choose a country" aria-label="Country">…</Select><Select placeholder="Choose a country" defaultValue="cl" aria-label="Country (chosen)">…</Select><select class="wayra-select" aria-label="Country"><option value="" disabled hidden selected>Choose a country</option> <option value="ar">Argentina</option> <option value="cl">Chile</option> <option value="pe">Peru</option></select><select class="wayra-select" aria-label="Country (chosen)"><option value="" disabled hidden>Choose a country</option><option value="ar">Argentina</option><option value="cl" selected>Chile</option><option value="pe">Peru</option></select>The pin is not decoration. HTML’s initial-selection algorithm skips disabled options, so
without it the browser would select Argentina and the prompt would never be seen — not
outside a form, and not inside one whose defaults do not carry the key. In hand-written
markup you write that pin yourself, as the selected attribute above.
A select has no ::placeholder and no :placeholder-shown, so the dimmed colour cannot come
from where Input’s does. It comes from :has(option[value=""]:disabled:checked) instead, and it
goes away the moment a real option is chosen. The disabled is what makes it a prompt: an
enabled empty-valued option — “Any country” — is an answer, and it is painted like one.
placeholderAsItem keeps the prompt in the open list instead of hiding it. It stays
disabled, so it can be read but never chosen back.
Multiple
Section titled “Multiple”multiple is the native attribute and it does more than the name suggests.
Code
<Select multiple label="Countries"> <option value="ar">Argentina</option> <option value="cl">Chile</option> <option value="pe">Peru</option></Select><select class="wayra-select" multiple aria-label="Countries"> <option value="ar">Argentina</option> <option value="cl">Chile</option> <option value="pe">Peru</option></select>| What changes | From | To |
|---|---|---|
| ARIA role | combobox |
listbox |
| Shape | one line with an arrow | a scrolling box, no arrow |
| Value | string |
string[] |
The role move is ARIA in HTML, not a choice: a <select> maps to combobox only while it
is a drop-down. So turning the prop on changes what assistive technology calls the control,
which is worth knowing before you toggle it from a prop of your own.
The stylesheet keys the same guard on [multiple] and on the native size attribute, since
size > 1 produces the same list box. Height becomes automatic with the step’s height as a
floor, the arrow is dropped, and the single-line centring is replaced by normal leading.
Inside a form the value is an array with nothing to configure: react-hook-form reads a
multi-select straight off the DOM through selectedOptions. Seeding defaultValues with
[] only matters in the writing direction — reset and setValue.
Styled picker
Section titled “Styled picker”A <select> has had one immovable limitation for as long as it has existed: the open list is
drawn by the operating system, and no stylesheet reaches it. The customizable select changes
that — where the browser supports it, the popup is a real element in the page.
The library opts in behind a @supports selector(::picker(select)) guard, so there are two
tiers and no polyfill:
| Engine | Closed control | Open list |
|---|---|---|
| Chrome 135+ | ours | ours — the same menu surface ComboBox opens |
| Safari, Firefox, older Chrome | ours | the OS popup, untouched |
The closed control is identical in both tiers, and that is the part worth checking rather
than trusting. appearance: base-select brings a UA box with it, but every part of that box
which matters — height, border, radius, padding and font size — is already declared at author
priority, so nothing moves. Two things do change, and neither moves the box: the base appearance
adds a second arrow of its own, which is dropped, and our own arrow stops being painted into the
control’s background and becomes a ::after box sitting in exactly the padding the background
version already reserved. It has to be a box, because a box can turn: the chevron rotates 180
degrees while the picker is open, the way a disclosure control does. Below the tier there is no
flip — the system popup covers the control anyway.
What the styled tier gets: the opaque menu surface with its hairline border, and
rows that are the same rows a ComboBox opens — same height, same inline
padding, same corner, same leading check, same one-line-with-an-ellipsis treatment of a long
label, same solid fill of the role colour with its -content pair on the highlighted or
hovered row (the chosen row keeps only its check, as a ComboBox row does), same air around an
<optgroup> label, and the same scale-and-fade entrance. Every one of those numbers is a single
shared variable; the PickerParity story asserts the two are computed-style equal rather than
merely described as such, and select.browser.test.ts opens the picker with a real click to
compare the highlighted rows.
It also fixes a real defect of the native control: the system popup is dismissed by Chrome as
soon as the page scrolls under it. The styled picker is in the top layer, anchored to the
field — it stays open and follows the control while the page moves. That one is verified by
hand, because a browser will not open a picker for a synthesised click; the equivalent guarantee
for the ComboBox is automated, in its ScrollKeepsOpen story.
multiple and size are excluded on purpose: a list box is not a popup, the base appearance
does not apply to one, and the guard in the stylesheet says so.
What’s not included
Section titled “What’s not included”No pill shape
Section titled “No pill shape”rounded and rectangle, the same pair Textarea ships. A max radius would curve into the
edge the arrow sits against.
No readOnly
Section titled “No readOnly”HTML has no readonly for <select> — the attribute does nothing on the element. When a
value must be shown but not changed, use disabled and post the value with a hidden input,
which is what the platform expects.
No search or filtering
Section titled “No search or filtering”A select with a text field in it is a different control with a different keyboard: printable
characters are type-ahead in a select and typing in a combobox, and six of the nine keys the
ARIA Authoring Practices define for the popup behave differently. So it is a separate
component — ComboBox — and not a searchable flag here.
Reach for it when filtering is the point: hundreds of rows, or rows that arrive from a server. Up to a few dozen options the native select is strictly better, and type-ahead, touch, Voice Control and Windows high contrast all come free.
The native size attribute is not a prop
Section titled “The native size attribute is not a prop”size is the size step here, as it is on Input and Button. The row-count attribute still
reaches the element through ComponentProps<"select"> if you really want it, and the
stylesheet already guards for it.
Label, helper, and error
Section titled “Label, helper, and error”The wrapper is the one Input documents, with one difference: a floating label over a select is always floated.
Code
<Select label="Country" labelPlacement="floating" placeholder="Choose a country"> <option value="ar">Argentina</option> <option value="cl">Chile</option> <option value="pe">Peru</option></Select><div class="wayra-label" data-placement="floating" data-size="md"><label class="wayra-label-text" for="float-country">Country</label><select class="wayra-select" id="float-country"> <option value="" disabled hidden selected>Choose a country</option> <option value="ar">Argentina</option> <option value="cl">Chile</option> <option value="pe">Peru</option></select></div>Input decides “empty” with :placeholder-shown, which a select can never match, and a select
always shows something anyway. So it takes the same branch as <input type="date">: the
label lifts on mount and stays there, and what shows inside the field is the prompt or the
chosen option.
Nothing to configure. name, ref, value, defaultValue and onChange all come from
ComponentProps<"select">, so a native form, TanStack Form or Formik work with no adapter.
The integration point is error plus aria-invalid.
react-hook-form is built in, exactly as it is on Input, and
by the same route: a <select> is a form control the browser already understands, so it
registers natively — no Controller, no hidden input. Inside Form, which provides the
useForm instance you own, a Select registers itself on name alone: the validation message
arrives in the hint slot and the field disables itself while the form submits. An explicit
error or disabled still wins, and noRhf turns the whole thing off for one instance.
const schema = z.object({ country: z.string().min(1, 'Choose a country.') });
const form = useForm({ defaultValues: { country: '' }, resolver: zodResolver(schema) });
<Form form={form} onSubmit={save}> <Select name="country" label="Country" placeholder="Choose a country"> <option value="ar">Argentina</option> <option value="cl">Chile</option> </Select> <Button type="submit">Save</Button></Form>Code
import { useState } from "react";import { Button, Select } from "wayra-ui";
export function FormDemo() { const [country, setCountry] = useState(""); const [error, setError] = useState<string>(); const [submitted, setSubmitted] = useState<string>();
function submit(event: React.SubmitEvent<HTMLFormElement>) { event.preventDefault(); if (country === "") { setError("Choose a country to continue."); setSubmitted(undefined); return; } setError(undefined); setSubmitted(country); }
return ( // The select stays uncontrolled: `placeholder` already pins // `defaultValue=""`, and adding `value` on top would leave React asking // which of the two owns the field. <form className="hu-demo-col" onSubmit={submit} noValidate> <Select name="country" label="Country" placeholder="Choose a country" helper={error ? undefined : "Where you are billed."} error={error} onChange={(event) => setCountry(event.currentTarget.value)} > <option value="ar">Argentina</option> <option value="cl">Chile</option> <option value="pe">Peru</option> </Select> <Button type="submit" width="block"> Save </Button> {submitted && <p className="hu-readout">submitted: {submitted}</p>} </form> );}API reference
Section titled “API reference”SelectProps extends ComponentProps<"select"> minus size, color, width, children
and name, which are redefined below.
| Prop | Type | Default | Description |
|---|---|---|---|
children |
ReactNode |
— required | The <option> and <optgroup> elements. |
variant |
"outline" | "filled" | "soft" | "ghost" |
"outline" |
As on Input. |
color |
WayraColor |
"primary" |
Edge and arrow on outline/soft/ghost, surface on filled. |
size |
"xs" | "sm" | "md" | "lg" | "xl" |
"md" |
Height, font size and padding. Replaces the HTML row count. |
shape |
"rounded" | "rectangle" |
"rounded" |
No pill. |
placeholder |
string |
— | Prepends a disabled, hidden, empty-valued option and selects it. |
placeholderAsItem |
boolean |
false |
Keeps that option visible in the list; it stays disabled. |
multiple |
boolean |
false |
Native. Moves the role to listbox and the value to string[]. |
noRhf |
boolean |
false |
Opts this instance out of the surrounding Form. |
label |
ReactNode |
— | Wraps the field in the label contract. |
labelPlacement |
"top" | "left" | "floating" |
"top" |
floating is always floated. |
helper |
ReactNode |
— | Persistent hint, wired into aria-describedby. |
error |
ReactNode |
— | Takes the hint slot, adds role="alert", sets aria-invalid. |
unstyled |
boolean |
false |
Drops the classes, keeps the data attributes. |
Everything else is the native attribute, typed: name, value, defaultValue, onChange,
ref, required, autoComplete and the rest.
Accessibility
Section titled “Accessibility”| Key | Behavior |
|---|---|
Tab / Shift+Tab |
Moves focus. Skips disabled. |
Space / Alt+↓ |
Opens the list. Platform behaviour, not ours. |
↑ / ↓ |
Moves through the options — on some platforms without opening the list. |
| Printable keys | Type-ahead by option text. This is what a filterable combobox replaces. |
Enter / Escape |
Commits or dismisses the open list. |
- Everything Input documents holds here: the ring on
:focusrather than:focus-visible,labeloveraria-label, the mergedaria-describedby, and theforced-colorsborder rescue. See Input’s accessibility notes. - The open list is the operating system’s, below the styled tier. Chrome and Firefox on
Windows and Linux respect
colorandbackground-coloron<option>; other desktop platforms hand the popup to a native menu widget, and mobile browsers open a system surface of their own. Where the styled picker applies the popup is ours instead, painted from the same tokens as the rest of the library. Either way afilledvariant’s-contentcolour does not reach the popup, and an automated audit only ever measures the closed control — a browser will not open a picker for a synthesised click. Do not rely on option styling to carry meaning. - The arrow is painted from a gradient, into the control’s background below the styled tier
and into a
::afterbox at it, and Windows high contrast erases gradients. At the styled tier the box opts out of forced colors and draws the arrow inCanvasText, through the same variable the variants set. Below it the arrow is the field’s own background, which cannot opt out without un-forcing the whole field, so it goes and the border alone draws the control. The styled tier’s check keeps its row’s system text colour there, as ComboBox’s does. multiplechanges the announced role fromcomboboxtolistbox. Do not toggle it from a prop without saying so in the label.
Do and don’t
Section titled “Do and don’t”Do
A prompt that is disabled and empty-valued. It can be read, it cannot be submitted, and it cannot be chosen back.
Don't
A real option pretending to be a prompt. It posts none as if it were an answer, and validation has to know that string.
Do
A visible label, plus a prompt inside the field. The name survives after the user answers.
Don't
The prompt used as the label. It disappears the moment a country is picked, and nothing on screen says what the field is.