Skip to content

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.

Change the props and copy the code.

Where you are billed.

variant
color
size
shape
label
content
state
Generated code
TSX
<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>
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.

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 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 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.

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.

rounded and rectangle, the same pair Textarea ships. A max radius would curve into the edge the arrow sits against.

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.

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.

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.

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>

Where you are billed.

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>
);
}

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.

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 :focus rather than :focus-visible, label over aria-label, the merged aria-describedby, and the forced-colors border 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 color and background-color on <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 a filled variant’s -content colour 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 ::after box at it, and Windows high contrast erases gradients. At the styled tier the box opts out of forced colors and draws the arrow in CanvasText, 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.
  • multiple changes the announced role from combobox to listbox. Do not toggle it from a prop without saying so in the label.

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.