Skip to content

Native Select

The browser's own select element, styled to match prfct. Best on mobile, in long forms and wherever zero JavaScript matters.

SourceRegistry
import { Field, FieldLabel } from "@/components/ui/field"
import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select"

export default function NativeSelectDemo() {
  return (
    <Field className="w-64">
      <FieldLabel htmlFor="native-select-demo">Billing cycle</FieldLabel>
      <NativeSelect
        id="native-select-demo"
        defaultValue="yearly"
        className="w-full"
      >
        <NativeSelectOption value="monthly">Monthly</NativeSelectOption>
        <NativeSelectOption value="quarterly">Quarterly</NativeSelectOption>
        <NativeSelectOption value="yearly">
          Yearly · save 20%
        </NativeSelectOption>
      </NativeSelect>
    </Field>
  )
}

Installation

$ pnpm dlx shadcn@latest add https://www.prfct.dev/r/native-select.json

Usage

import {
  NativeSelect,
  NativeSelectOptGroup,
  NativeSelectOption,
} from "@/components/ui/native-select"
<NativeSelect defaultValue="yearly">
  <NativeSelectOption value="monthly">Monthly</NativeSelectOption>
  <NativeSelectOption value="yearly">Yearly</NativeSelectOption>
</NativeSelect>

className styles the wrapper, which is w-fit by default — pass a width such as w-full to size the control. Every other prop goes to the <select> element.

Examples

Sizes

sm, default and lg match the heights of Input and Button, so a filter and its action line up.

import { Button } from "@/components/ui/button"
import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select"

const sizes = ["sm", "default", "lg"] as const

export default function NativeSelectSizes() {
  return (
    <div className="flex flex-col gap-4">
      {sizes.map((size) => (
        <div key={size} className="flex items-center gap-2">
          <NativeSelect
            size={size}
            defaultValue="7"
            aria-label={`Date range (${size})`}
            className="w-48"
          >
            <NativeSelectOption value="1">Last 24 hours</NativeSelectOption>
            <NativeSelectOption value="7">Last 7 days</NativeSelectOption>
            <NativeSelectOption value="30">Last 30 days</NativeSelectOption>
          </NativeSelect>
          <Button size={size} variant="outline">
            Export
          </Button>
        </div>
      ))}
    </div>
  )
}

Option groups

NativeSelectOptGroup adds non-selectable headings to long lists. Operating systems render them natively — indented on desktop, sectioned in mobile pickers.

import { Field, FieldDescription, FieldLabel } from "@/components/ui/field"
import {
  NativeSelect,
  NativeSelectOptGroup,
  NativeSelectOption,
} from "@/components/ui/native-select"

const zones = {
  Americas: [
    ["America/New_York", "New York (UTC−4)"],
    ["America/Chicago", "Chicago (UTC−5)"],
    ["America/Los_Angeles", "Los Angeles (UTC−7)"],
  ],
  Europe: [
    ["Europe/London", "London (UTC+1)"],
    ["Europe/Warsaw", "Warsaw (UTC+2)"],
    ["Europe/Helsinki", "Helsinki (UTC+3)"],
  ],
  "Asia Pacific": [
    ["Asia/Singapore", "Singapore (UTC+8)"],
    ["Asia/Tokyo", "Tokyo (UTC+9)"],
    ["Australia/Sydney", "Sydney (UTC+10)"],
  ],
}

export default function NativeSelectGroups() {
  return (
    <Field className="w-72">
      <FieldLabel htmlFor="native-select-zone">Time zone</FieldLabel>
      <NativeSelect
        id="native-select-zone"
        defaultValue="Europe/Warsaw"
        className="w-full"
      >
        {Object.entries(zones).map(([region, options]) => (
          <NativeSelectOptGroup key={region} label={region}>
            {options.map(([value, label]) => (
              <NativeSelectOption key={value} value={value}>
                {label}
              </NativeSelectOption>
            ))}
          </NativeSelectOptGroup>
        ))}
      </NativeSelect>
      <FieldDescription>
        Used for scheduled reports and reminders.
      </FieldDescription>
    </Field>
  )
}

Invalid and disabled

A disabled, empty first option works as a placeholder: combined with required, the browser refuses to submit until a real choice is made. Mark invalid selections with aria-invalid and a FieldError, exactly as you would an input.

import {
  Field,
  FieldError,
  FieldGroup,
  FieldLabel,
} from "@/components/ui/field"
import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select"

export default function NativeSelectStates() {
  return (
    <FieldGroup className="grid w-full max-w-xl gap-5 sm:grid-cols-2">
      <Field data-invalid>
        <FieldLabel htmlFor="native-select-invalid">Country</FieldLabel>
        <NativeSelect
          id="native-select-invalid"
          defaultValue=""
          required
          aria-invalid
          aria-describedby="native-select-invalid-error"
          className="w-full"
        >
          <NativeSelectOption value="" disabled>
            Select a country
          </NativeSelectOption>
          <NativeSelectOption value="pl">Poland</NativeSelectOption>
          <NativeSelectOption value="de">Germany</NativeSelectOption>
        </NativeSelect>
        <FieldError id="native-select-invalid-error">
          Choose where you pay taxes.
        </FieldError>
      </Field>
      <Field data-disabled>
        <FieldLabel htmlFor="native-select-disabled">Currency</FieldLabel>
        <NativeSelect
          id="native-select-disabled"
          defaultValue="eur"
          disabled
          className="w-full"
        >
          <NativeSelectOption value="eur">Euro (EUR)</NativeSelectOption>
        </NativeSelect>
      </Field>
    </FieldGroup>
  )
}

Guidelines

When to use

  • Mobile-heavy flows: phones open their platform picker, which is faster and familiar.
  • Long forms and server-rendered pages that must work before — or without — JavaScript.
  • Simple lists of plain-text options where custom rendering adds nothing.

When not to use

  • Options that need icons, descriptions or custom layout — use Select.
  • Lists long enough to search — use a Combobox.
  • Two to five options people should compare at a glance — use a Radio Group or Toggle Group.

Native or custom?

ConcernNative SelectSelect
MobilePlatform pickerprfct popup
Works before hydrationYesNo
Rich option contentText onlyIcons, descriptions, any JSX
Consistent look across platformsTrigger onlyTrigger and list
Typeahead and keyboardNativeImplemented by Base UI

Choose one per product surface and stay consistent; mixing both in one form makes it feel unfinished.

Accessibility

Native Select is a real <select>, so it inherits the platform's keyboard support, screen reader semantics and form behavior with no scripting.

  • Label it with a FieldLabel and htmlFor/id, or an aria-label for compact filters.
  • System colors. Options use the Canvas and CanvasText system colors, so the open list follows the operating system and the page's color-scheme in dark mode.
  • Contrast. The border uses the input token at 3:1 against the page; the chevron is decorative and hidden from assistive technology.
KeyBehavior
Tab
Moves focus to the select.
SpaceEnterAlt↓
Opens the list (platform-dependent).
↑↓
Changes the selection — without opening the list on most desktop browsers.
A–Z
Jumps to the next option starting with that letter.

API reference

NativeSelect

Renders a wrapper <div> with a <select> and a chevron icon. Accepts all native select props.

PropTypeDefault
size

Height and padding: 32, 36 or 40px.

"sm" | "default" | "lg""default"
className

Applied to the wrapper. Use it for width and layout.

stringNo default
value

The controlled value. Pair with onChange.

stringNo default
defaultValue

The initial value of an uncontrolled select.

stringNo default
disabled

Disables the select and dims it.

booleanfalse
aria-invalid

Shows the invalid border and focus halo.

booleanNo default

NativeSelectOption

Renders an <option>. Accepts all native option props, including value and disabled.

NativeSelectOptGroup

Renders an <optgroup>. Set its label to the group heading.