HextaUI

Native select

The browser's own select, styled to match Input, with a quiet placeholder, Field support and the OS picker on every device.

import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select"

export function NativeSelectDemo() {
  return (
    <div className="grid w-full max-w-sm gap-4">
      <div className="flex flex-col gap-2">
        <Label htmlFor="native-select-demo-name">Project name</Label>
        <Input id="native-select-demo-name" defaultValue="HextaUI" />
      </div>
      <div className="flex flex-col gap-2">
        <Label htmlFor="native-select-demo-framework">Framework</Label>
        <NativeSelect id="native-select-demo-framework" className="w-full">
          <NativeSelectOption value="">Select a framework</NativeSelectOption>
          <NativeSelectOption value="next">Next.js</NativeSelectOption>
          <NativeSelectOption value="remix">Remix</NativeSelectOption>
          <NativeSelectOption value="astro">Astro</NativeSelectOption>
          <NativeSelectOption value="vite">Vite</NativeSelectOption>
        </NativeSelect>
      </div>
    </div>
  )
}
pnpm dlx shadcn@latest add https://hextaui.com/r/native-select.json

Adds the component, the HextaUI theme tokens and any HextaUI components it depends on.

import {
  NativeSelect,
  NativeSelectOptGroup,
  NativeSelectOption,
} from "@/components/ui/native-select"
<NativeSelect>
  <NativeSelectOption value="">Select a fruit</NativeSelectOption>
  <NativeSelectOption value="apple">Apple</NativeSelectOption>
  <NativeSelectOption value="banana">Banana</NativeSelectOption>
</NativeSelect>

An option with an empty value acts as the placeholder: it shows in muted text until something is chosen. Put className on the select to size its wrapper, for example w-full.

NativeSelect
├── NativeSelectOption
└── NativeSelectOptGroup
    └── NativeSelectOption

Groups

<NativeSelectOptGroup /> organizes options under headings in the OS picker.

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

export function NativeSelectGroups() {
  return (
    <NativeSelect aria-label="Department">
      <NativeSelectOption value="">Select department</NativeSelectOption>
      <NativeSelectOptGroup label="Engineering">
        <NativeSelectOption value="frontend">Frontend</NativeSelectOption>
        <NativeSelectOption value="backend">Backend</NativeSelectOption>
        <NativeSelectOption value="devops">DevOps</NativeSelectOption>
      </NativeSelectOptGroup>
      <NativeSelectOptGroup label="Design">
        <NativeSelectOption value="product">Product design</NativeSelectOption>
        <NativeSelectOption value="brand">Brand</NativeSelectOption>
      </NativeSelectOptGroup>
      <NativeSelectOptGroup label="Operations">
        <NativeSelectOption value="support">
          Customer support
        </NativeSelectOption>
        <NativeSelectOption value="people">People</NativeSelectOption>
      </NativeSelectOptGroup>
    </NativeSelect>
  )
}

Sizes

Sizes, borders, focus rings and invalid styles match Input exactly, so the two line up in a row.

import { Input } from "@/components/ui/input"
import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select"

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

export function NativeSelectSizes() {
  return (
    <div className="flex w-full max-w-md flex-col gap-3">
      {sizes.map((size) => (
        <div key={size} className="flex gap-2">
          <Input
            size={size}
            placeholder={`Input ${size}`}
            aria-label={`Input ${size}`}
          />
          <NativeSelect size={size} aria-label={`Select ${size}`}>
            <NativeSelectOption value="">{size}</NativeSelectOption>
            <NativeSelectOption value="a">Option A</NativeSelectOption>
          </NativeSelect>
        </div>
      ))}
    </div>
  )
}

Field

Inside a <Field />, the label and description connect on their own and errors show as the value changes. Submit without a plan and the select turns red, shakes once, and the browser points to it.

You can change this later.

"use client"

import { Form } from "@base-ui/react/form"

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

export function NativeSelectField() {
  return (
    <Form
      className="flex w-full max-w-sm flex-col gap-4"
      onSubmit={(event) => event.preventDefault()}
    >
      <Field validationMode="onChange">
        <FieldLabel>Plan</FieldLabel>
        <NativeSelect name="plan" required className="w-full">
          <NativeSelectOption value="">Choose a plan</NativeSelectOption>
          <NativeSelectOption value="hobby">Hobby</NativeSelectOption>
          <NativeSelectOption value="pro">Pro</NativeSelectOption>
          <NativeSelectOption value="team">Team</NativeSelectOption>
        </NativeSelect>
        <FieldDescription>You can change this later.</FieldDescription>
        <FieldError match="valueMissing">Choose a plan to continue.</FieldError>
      </Field>
      <Button type="submit" className="self-start">
        Continue
      </Button>
    </Form>
  )
}

Invalid

Set aria-invalid and link the message with aria-describedby.

We don't ship to the selected region yet.

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

export function NativeSelectInvalid() {
  return (
    <div className="flex w-full max-w-sm flex-col gap-2">
      <NativeSelect
        aria-label="Country"
        aria-invalid
        aria-describedby="native-select-invalid-message"
        className="w-full"
      >
        <NativeSelectOption value="">Select a country</NativeSelectOption>
        <NativeSelectOption value="in">India</NativeSelectOption>
        <NativeSelectOption value="de">Germany</NativeSelectOption>
      </NativeSelect>
      <p
        id="native-select-invalid-message"
        className="text-sm text-destructive"
      >
        We don&apos;t ship to the selected region yet.
      </p>
    </div>
  )
}

Disabled

Disable the whole select, or single options that aren't available.

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

export function NativeSelectDisabled() {
  return (
    <div className="flex flex-wrap items-center gap-3">
      <NativeSelect aria-label="Region" disabled defaultValue="eu">
        <NativeSelectOption value="us">United States</NativeSelectOption>
        <NativeSelectOption value="eu">Europe</NativeSelectOption>
      </NativeSelect>
      <NativeSelect aria-label="Plan" defaultValue="pro">
        <NativeSelectOption value="hobby">Hobby</NativeSelectOption>
        <NativeSelectOption value="pro">Pro</NativeSelectOption>
        <NativeSelectOption value="enterprise" disabled>
          Enterprise (contact sales)
        </NativeSelectOption>
      </NativeSelect>
    </div>
  )
}

Controlled

Pass value and onChange like a regular select.

09:00 UTC is 2:30 PM there.

"use client"

import * as React from "react"

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

const zones = [
  { value: "America/New_York", label: "New York" },
  { value: "Europe/London", label: "London" },
  { value: "Asia/Kolkata", label: "Kolkata" },
  { value: "Asia/Tokyo", label: "Tokyo" },
]

export function NativeSelectControlled() {
  const [zone, setZone] = React.useState("Asia/Kolkata")
  const time = new Intl.DateTimeFormat("en-US", {
    hour: "numeric",
    minute: "2-digit",
    timeZone: zone,
  }).format(new Date(Date.UTC(2026, 9, 4, 9, 0)))

  return (
    <div className="flex flex-col items-start gap-2">
      <NativeSelect
        aria-label="Time zone"
        value={zone}
        onChange={(event) => setZone(event.target.value)}
      >
        {zones.map((option) => (
          <NativeSelectOption key={option.value} value={option.value}>
            {option.label}
          </NativeSelectOption>
        ))}
      </NativeSelect>
      <p className="text-sm text-muted-foreground">
        09:00 UTC is {time} there.
      </p>
    </div>
  )
}

Long content

Long selected labels end with an ellipsis instead of running under the chevron.

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

export function NativeSelectLongContent() {
  return (
    <div className="w-56 max-w-full">
      <NativeSelect
        aria-label="Billing account"
        defaultValue="long"
        className="w-full"
      >
        <NativeSelectOption value="short">Personal</NativeSelectOption>
        <NativeSelectOption value="long">
          The International Subsidiary Billing Account for Europe and Asia
        </NativeSelectOption>
      </NativeSelect>
    </div>
  )
}

Right to left

The chevron and padding move to the other side.

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

export function NativeSelectRtl() {
  return (
    <div dir="rtl">
      <NativeSelect aria-label="اللغة">
        <NativeSelectOption value="">اختر لغة</NativeSelectOption>
        <NativeSelectOption value="ar">العربية</NativeSelectOption>
        <NativeSelectOption value="en">الإنجليزية</NativeSelectOption>
        <NativeSelectOption value="fr">الفرنسية</NativeSelectOption>
      </NativeSelect>
    </div>
  )
}
  • Use Native select for simple lists, especially on phones, where the OS picker is the fastest and most familiar way to choose.
  • Use Combobox when people need to search, or when options need icons, descriptions or custom layouts.
KeyAction
TabFocuses the select.
SpaceEnterOpens the OS picker.
↑↓Changes the selection, or moves in the open picker.
A–ZJumps to the option that starts with the typed text.

<NativeSelect /> accepts every select attribute. The option parts accept their element's attributes.

PropTypeDefault
size
"sm" | "default" | "lg""default"
htmlSizeThe native size attribute, renamed because size is the variant.
number–
shakeShake once when a form submit finds it invalid.
booleantrue
classNameApplied to the wrapper, so it sizes the whole control.
string–
value
string–
defaultValue
string–
disabled
booleanfalse
required
booleanfalse
AttributeDescription
data-slot="native-select-wrapper"The wrapper, with data-size.
data-slot="native-select"The select element.
data-slot="native-select-icon"The chevron. Darkens on hover and focus.
data-invalidPresent when a surrounding Field is invalid.
AttributeDescription
data-slot="native-select-option"An option.
data-slot="native-select-optgroup"A labelled group of options.