Native Select
A form field built on a real <select> — the platform's own menu on a phone, the platform's keyboard model everywhere, and no JavaScript needed to make it accessible.
Usage
Anatomy
Import the NativeSelect component and access all parts using dot notation.
import {NativeSelect} from "@blakeui/pro-react";
<NativeSelect>
<NativeSelect.Label />
<NativeSelect.Trigger>
<NativeSelect.OptGroup>
<NativeSelect.Option />
</NativeSelect.OptGroup>
</NativeSelect.Trigger>
<NativeSelect.Description />
<NativeSelect.FieldError />
</NativeSelect>;NativeSelect.Trigger supplies the indicator when you leave it alone, so a plain field needs only a
label and a trigger.
Controlled
Deploys go straight out.
Custom indicator
Disabled
Billing is managed by your workspace owner.
Form example
Invalid
Sets the tax rate applied to your invoices.
Choose a billing country to continue.
Optgroups
Placeholder
Roles can be changed at any time.
Sizes
Variants
With disabled options
Greyed regions are full or not open yet.
With label and description
Meeting invitations and digests are sent in this zone.
CSS Classes
Base Classes
.native-select— The root<div>.display: flexin a column,gap: 4px,width: 100%,min-width: 0. It paints nothing; it stacks the parts and carriesdata-required/data-invalid/data-disabled, which is what the shipped.labelrules from@blakeui/styleskey off.
Element Classes
.native-select__trigger— The positioning shell around the control.position: relative,display: flex, full width, and no padding or paint of its own. It exists only so the chevron has something to position against; giving it padding would put a strip of dead space inside what looks like the control..native-select__select— The<select>, and the visible box.appearance: none,width: 100%,1px solidatvar(--field-radius),box-shadow: none,cursor: var(--cursor-interactive),color: var(--foreground). Its type is1rem/1.5remand drops to0.875rem/1.25remat40rem— the 16px floor below that breakpoint is the guard that stops iOS zooming the page when the control takes focus, the same one the free.inputand.select__valuecarry. Its transition isbackground-colorandborder-colorover 150ms onvar(--ease-out)— colour only, so there is no movement for reduced motion to remove and no opt-out to write..native-select__select--default—1px solid var(--border)overvar(--surface). Bordered-flat: a stroke and a fill, never elevation..native-select__select--secondary— the same stroke overvar(--surface-secondary)..native-select__select--tertiary— the same stroke overvar(--surface-tertiary)..native-select__select--md—36pxminimum height,6pxblock padding,12pxleading and32pxtrailing inline padding. The trailing figure is the lane the chevron sits in..native-select__select--sm—32pxminimum height,4pxblock padding,10pxleading and28pxtrailing inline padding. Only the box changes; the type scale is shared withmd, the same way.button--smshortens a button without restyling its label..native-select__indicator— The chevron's<span>. Absolutely positioned,inset-block: 0, centred,color: var(--field-placeholder), andpointer-events: none— that last one is load-bearing, since the element sits over the control and would otherwise swallow every click in that corner..native-select__indicator--md/.native-select__indicator--sm—12px/10pxfrom the trailing edge, with a16px/14pxglyph..native-select__label— The<label>. Also carries the shipped.label, so its type, the required asterisk, the invalid ink and the disabled dim are all@blakeui/styles'. This class addswidth: fit-contentso the click target ends with the text rather than running the width of the field..native-select__description— The supporting line. Also carries the shipped.description(0.75rem,var(--muted)). This class adds a4pxinline inset, matching the one.field-erroralready ships, so the two supporting lines agree with each other..native-select__error— The error line. Also carries the shipped.field-error, and is rendered withdata-visible="true"because it is only in the DOM at all when the field is invalid..native-select__option/.native-select__optgroup— Hooks with no paint behind them. Once the menu is open it belongs to the operating system, and an<option>takes almost nothing from CSS on macOS or iOS; the classes exist so the family composes and so a theme has somewhere to attach.
Hover, focus, invalid and disabled
- Hover steps the fill one rung down the surface ladder, inside
@media (hover: hover)and skipped on:disabled.defaultuses the shipped--surface-hover; the other two take the same mix explicitly, against--foregroundand not--surface-secondary-foreground— the latter is declared only in the:rootblock of@blakeui/stylesand never restated for dark, so under a theme scoped to a subtree it still holds the light ink and the step would run the wrong way. - Focus is
outline: 2px solid var(--focus)at0offset on:focus-visible, with the user agent's own ring dropped on the:focusbranch that ours replaces. Anoutlinerather than a ring: it followsborder-radius, costs nobox-shadow, and is a single property, so the invalid rule can recolour it instead of drawing a second stroke beside it. - Invalid turns the stroke the field already has to
var(--danger)and recolours the focus outline to match. The free fields add a danger outline outside their border because they have no border to recolour; this family does, so recolouring it is the equivalent. The description is set todisplay: noneand the error takes its place, matching.selectand.textfield. - Disabled keys off the element's own
:disabled, so it holds whether the state came fromisDisabledon the root ordisabledon the trigger:opacity: var(--disabled-opacity)andcursor: var(--cursor-disabled)on the select, and the same opacity on the indicator through a sibling selector. The label dims separately, through@blakeui/styles'[data-disabled="true"] .label— two rules rather than one on an ancestor, because dimming a parent would compound with that label rule and take the label to a quarter opacity. - The placeholder's muted ink comes from
.native-select__select:has(option[data-placeholder="true"]:checked). A placeholder is showing exactly when it is the selected option, which is a state the element already publishes — so this needs no JavaScript, no mirrored value, and it survives a form reset that a mirrored value would not.
Motion
There is none, deliberately. The only transition is the control's colour crossfade, which has no movement in it and therefore needs no reduced-motion opt-out. The option list is the operating system's menu and is not ours to animate, and the chevron is static because the menu's open state is not observable — a chevron animated against a menu we do not own would go out of sync the first time someone dismissed it with the keyboard.
API Reference
NativeSelect
The field wrapper and the owner of every id in it. It renders no control of its own: it stacks the
parts, mints the ids that tie them together, and stamps the state attributes the shipped .label
rules read. The state props are React-Aria-shaped rather than the DOM's because they drive more than
the select — the label and the error read them too — and the root translates them onto the <select>,
which is the only place they actually belong. Also accepts every native <div> attribute.
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | — | The field's parts — a Label, a Trigger, and optionally a Description and a FieldError. Required. |
variant | "default" | "secondary" | "tertiary" | "default" | Surface treatment of the control. All three are bordered-flat and differ only by fill, stepping down the same surface ladder ItemCard uses. Painted by the <select>, which is what carries the modifier — the root itself paints nothing. |
size | "sm" | "md" | "md" | Control height and padding: 32px or 36px. The type scale is shared between them, so two fields at different heights still read as one voice. Below the 40rem breakpoint both grow, because the text steps up to 16px there to stop iOS zooming the page. |
isDisabled | boolean | false | Puts disabled on the select and dims the field. Prefer it over disabled on the trigger: only the root's version stamps data-disabled, which is what dims the label with it. |
isInvalid | boolean | false | Puts aria-invalid on the select, turns the stroke and the label danger, hides the description, and renders the FieldError — which is the only state in which that part is in the DOM at all. |
isRequired | boolean | false | Puts required on the select and the asterisk on the label. Pair it with a placeholder so the field has an empty value for the browser to reject. |
id | string | — | Id for the <select>, and what the label points at — not an id for the root wrapper. Generated when omitted. |
className | string | — | Class name for the root wrapper. |
NativeSelect.Label
A real <label for> pointing at the real <select id> — not an aria-label, and not a label
wrapped around the control. The explicit pairing is what makes clicking the text focus the select and
open its menu, and it is the association assistive tech reads. Also accepts every native <label>
attribute except htmlFor, which the root owns.
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | — | The label text. Required. |
className | string | — | Class name for the label. |
NativeSelect.Trigger
The control. It renders the <select> itself, plus the shell the chevron positions against and the
indicator. The <select> carries the border, the fill and the padding, so every pixel of the visible
box is the control — there is no strip of parent padding that looks live and is not. Also accepts
every native <select> attribute — name, value, defaultValue, onChange, autoComplete,
form — except size, which the root's own size prop replaces, and multiple, which is typed
away: a multi-select renders as a list box rather than a menu, which the chevron, the height and the
placeholder would all be wrong for.
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | — | The options — NativeSelect.Option and NativeSelect.OptGroup, or plain <option> and <optgroup> elements. |
placeholder | string | — | Renders a leading <option value="" disabled hidden> and starts the field on it. disabled stops it being chosen and hidden keeps it out of the menu, so the prompt shows until a real choice is made and never comes back. The select is put on it by default because a disabled option is not what a browser picks on its own; a supplied value or defaultValue wins. |
indicator | ReactNode | NativeSelect.Indicator | Replaces the whole indicator element. Pass a NativeSelect.Indicator with your own glyph inside it to keep the positioning and the pointer transparency. |
disabled | boolean | — | The native attribute. Falls back to the root's isDisabled; setting it here disables the select but leaves the label at full strength, which is why the root's prop is the one to reach for. |
required | boolean | — | The native attribute. Falls back to the root's isRequired. |
className | string | — | Class name for the <select> — the visible box — not for the shell around it. |
NativeSelect.Indicator
The chevron, as a decorative sibling of the select rather than anything inside it. aria-hidden,
because the select already announces itself as a combo box, and pointer-events: none, so a click
on the glyph reaches the control underneath. Static by design — see Motion, above. Also accepts every
native <span> attribute.
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | ChevronsExpandVertical | A replacement glyph. The wrapper keeps its position, size and pointer transparency. |
className | string | — | Class name for the indicator. |
NativeSelect.Option and NativeSelect.OptGroup
A native <option> and a native <optgroup> with a class on each. They add no behaviour and neither
is required — a hand-written <option> works identically. They exist so a page composes one family
of parts, and so the two elements have somewhere for a theme to attach. What the open menu looks like
is the platform's, not ours. Both accept every attribute of the element they render, value,
disabled and label included.
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | — | The option's text, or the group's options. An <option> takes text only. |
className | string | — | Class name for the option or the group. |
NativeSelect.Description
The supporting line under the control, and the select's accessible description while the field is
valid. It registers itself on mount, so aria-describedby only ever names an element that is
actually on the page — composed parts cannot be counted any other way, since scanning children
breaks the moment one of them is wrapped. Also accepts every native <p> attribute except id,
which the root owns.
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | — | The description text. Required. |
className | string | — | Class name for the description. |
NativeSelect.FieldError
The error line, and the select's accessible description while the field is invalid. It renders
only when the root is invalid. The alternative — keeping it mounted at zero height and zero
opacity so it can transition in — leaves the text in the accessibility tree on a field that is
perfectly valid, because neither zero opacity nor zero height removes an element from it. Also
accepts every native <p> attribute except id, which the root owns.
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | — | The error text. Required. |
className | string | — | Class name for the error. |
Inline Select
A minimal select that blends into surrounding text — no stroke, no fill, sized by its own type scale — for a contextual choice that does not need a form field.
Number Stepper
A typeable numeric field with a decrement and an increment button attached — a segmented pill whose value rolls, with every step, clamp and format behaviour taken from React Aria.