Forms

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

Nothing submitted yet.

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: flex in a column, gap: 4px, width: 100%, min-width: 0. It paints nothing; it stacks the parts and carries data-required / data-invalid / data-disabled, which is what the shipped .label rules from @blakeui/styles key 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 solid at var(--field-radius), box-shadow: none, cursor: var(--cursor-interactive), color: var(--foreground). Its type is 1rem / 1.5rem and drops to 0.875rem / 1.25rem at 40rem — 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 .input and .select__value carry. Its transition is background-color and border-color over 150ms on var(--ease-out) — colour only, so there is no movement for reduced motion to remove and no opt-out to write.
  • .native-select__select--default1px solid var(--border) over var(--surface). Bordered-flat: a stroke and a fill, never elevation.
  • .native-select__select--secondary — the same stroke over var(--surface-secondary).
  • .native-select__select--tertiary — the same stroke over var(--surface-tertiary).
  • .native-select__select--md36px minimum height, 6px block padding, 12px leading and 32px trailing inline padding. The trailing figure is the lane the chevron sits in.
  • .native-select__select--sm32px minimum height, 4px block padding, 10px leading and 28px trailing inline padding. Only the box changes; the type scale is shared with md, the same way .button--sm shortens a button without restyling its label.
  • .native-select__indicator — The chevron's <span>. Absolutely positioned, inset-block: 0, centred, color: var(--field-placeholder), and pointer-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--sm12px / 10px from the trailing edge, with a 16px / 14px glyph.
  • .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 adds width: fit-content so 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 a 4px inline inset, matching the one .field-error already 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 with data-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. default uses the shipped --surface-hover; the other two take the same mix explicitly, against --foreground and not --surface-secondary-foreground — the latter is declared only in the :root block of @blakeui/styles and 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) at 0 offset on :focus-visible, with the user agent's own ring dropped on the :focus branch that ours replaces. An outline rather than a ring: it follows border-radius, costs no box-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 to display: none and the error takes its place, matching .select and .textfield.
  • Disabled keys off the element's own :disabled, so it holds whether the state came from isDisabled on the root or disabled on the trigger: opacity: var(--disabled-opacity) and cursor: 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.

PropTypeDefaultDescription
childrenReactNodeThe 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.
isDisabledbooleanfalsePuts 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.
isInvalidbooleanfalsePuts 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.
isRequiredbooleanfalsePuts 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.
idstringId for the <select>, and what the label points at — not an id for the root wrapper. Generated when omitted.
classNamestringClass 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.

PropTypeDefaultDescription
childrenReactNodeThe label text. Required.
classNamestringClass 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.

PropTypeDefaultDescription
childrenReactNodeThe options — NativeSelect.Option and NativeSelect.OptGroup, or plain <option> and <optgroup> elements.
placeholderstringRenders 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.
indicatorReactNodeNativeSelect.IndicatorReplaces the whole indicator element. Pass a NativeSelect.Indicator with your own glyph inside it to keep the positioning and the pointer transparency.
disabledbooleanThe 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.
requiredbooleanThe native attribute. Falls back to the root's isRequired.
classNamestringClass 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.

PropTypeDefaultDescription
childrenReactNodeChevronsExpandVerticalA replacement glyph. The wrapper keeps its position, size and pointer transparency.
classNamestringClass 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.

PropTypeDefaultDescription
childrenReactNodeThe option's text, or the group's options. An <option> takes text only.
classNamestringClass 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.

PropTypeDefaultDescription
childrenReactNodeThe description text. Required.
classNamestringClass 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.

PropTypeDefaultDescription
childrenReactNodeThe error text. Required.
classNamestringClass name for the error.

On this page