Native select
The browser's own select with the kit's field shape and a chevron.
A real select element — the platform's own picker on a phone — with its boundary at --input and a chevron drawn over it, as in shadcn's base-nova. Vibra focuses it with the kit's solid 2px accent outline, as it does Input and Select, where shadcn's draws a translucent halo, and keeps its padding and the chevron on the inline axis, so a right-to-left select mirrors. It stands on the card plane in both themes, as Input and the Select trigger do, where shadcn's wears a translucent input tint in the dark that reads a step lighter than the fields beside it. className styles the wrapper that holds the chevron; every other prop reaches the select.
Install
npx shadcn@latest add @vibra/native-selectNeeds the @vibra registry in your components.json — set it up once.
Examples
import { Label } from "@/components/ui/label"
import { NativeSelect, NativeSelectOptGroup, NativeSelectOption } from "@/components/ui/native-select"
export default function NativeSelectDemo() {
return (
<div className="flex w-full max-w-xs flex-col gap-2">
<Label htmlFor="report-currency">Reporting currency</Label>
<NativeSelect id="report-currency" defaultValue="EUR">
<NativeSelectOptGroup label="Most used">
<NativeSelectOption value="EUR">Euro (EUR)</NativeSelectOption>
<NativeSelectOption value="USD">US dollar (USD)</NativeSelectOption>
<NativeSelectOption value="GBP">Pound sterling (GBP)</NativeSelectOption>
</NativeSelectOptGroup>
<NativeSelectOptGroup label="Others">
<NativeSelectOption value="CHF">Swiss franc (CHF)</NativeSelectOption>
<NativeSelectOption value="JPY">Japanese yen (JPY)</NativeSelectOption>
<NativeSelectOption value="SGD">Singapore dollar (SGD)</NativeSelectOption>
</NativeSelectOptGroup>
</NativeSelect>
</div>
)
}Small, in a table footer
size="sm" beside the page buttons: as wide as its longest number, and the phone's own picker where it matters most.
"use client"
import * as React from "react"
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select"
const TOTAL = 1284
// size="sm" in a table's footer: 28px tall beside the page buttons, and as
// wide as its longest number. On a phone the platform's own picker opens,
// which is the reason to reach for the native select in a spot this small.
export default function NativeSelectFooter() {
const id = React.useId()
const [perPage, setPerPage] = React.useState(25)
const [page, setPage] = React.useState(1)
const pages = Math.ceil(TOTAL / perPage)
const first = (page - 1) * perPage + 1
const last = Math.min(page * perPage, TOTAL)
return (
<div className="flex w-full max-w-md flex-wrap items-center justify-between gap-3 border-t border-border pt-3">
<div className="flex items-center gap-2">
<label htmlFor={`${id}-rows`} className="text-xs text-muted-foreground">
Rows per page
</label>
<NativeSelect
id={`${id}-rows`}
size="sm"
value={perPage}
onChange={(event) => {
setPerPage(Number(event.target.value))
setPage(1)
}}
>
{[25, 50, 100].map((count) => (
<NativeSelectOption key={count} value={count}>
{count}
</NativeSelectOption>
))}
</NativeSelect>
</div>
<div className="flex items-center gap-2">
<p aria-live="polite" className="text-xs text-muted-foreground tabular-nums">
{first.toLocaleString("en-US")}–{last.toLocaleString("en-US")} of {TOTAL.toLocaleString("en-US")} orders
</p>
<Button variant="outline" size="icon-sm" aria-label="Previous page" disabled={page === 1} focusableWhenDisabled onClick={() => setPage(page - 1)}>
<ChevronLeftIcon aria-hidden="true" className="rtl:rotate-180" />
</Button>
<Button variant="outline" size="icon-sm" aria-label="Next page" disabled={page === pages} focusableWhenDisabled onClick={() => setPage(page + 1)}>
<ChevronRightIcon aria-hidden="true" className="rtl:rotate-180" />
</Button>
</div>
</div>
)
}Beside its label
Settings rows: the label and its hint at the start, the select at the end and described by the hint; the row stacks on a phone.
import { Label } from "@/components/ui/label"
import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select"
const SETTINGS = [
{
key: "week-start",
label: "Week starts on",
hint: "The first column of every weekly report.",
value: "monday",
options: [
{ value: "monday", label: "Monday" },
{ value: "sunday", label: "Sunday" },
{ value: "saturday", label: "Saturday" },
],
},
{
key: "date-format",
label: "Date format",
hint: "How dates read in tables and exports.",
value: "dmy",
options: [
{ value: "dmy", label: "04/09/2026" },
{ value: "mdy", label: "09/04/2026" },
{ value: "iso", label: "2026-09-04" },
],
},
]
// Settings rows: the label and its hint on the start side, the select on the
// end, as wide as its longest option. The hint describes the select, so it is
// read when the select takes the focus. Under a phone's width the row stacks.
export default function NativeSelectInline() {
return (
<div className="flex w-full max-w-md flex-col divide-y divide-border">
{SETTINGS.map((setting) => (
<div key={setting.key} className="flex flex-col gap-2 py-3 first:pt-0 last:pb-0 sm:flex-row sm:items-center sm:justify-between sm:gap-6">
<div className="flex flex-col gap-0.5">
<Label htmlFor={`native-select-inline-${setting.key}`}>{setting.label}</Label>
<span id={`native-select-inline-${setting.key}-hint`} className="text-xs text-muted-foreground">
{setting.hint}
</span>
</div>
<NativeSelect
id={`native-select-inline-${setting.key}`}
defaultValue={setting.value}
aria-describedby={`native-select-inline-${setting.key}-hint`}
className="w-full sm:w-40"
>
{setting.options.map((option) => (
<NativeSelectOption key={option.value} value={option.value}>
{option.label}
</NativeSelectOption>
))}
</NativeSelect>
</div>
))}
</div>
)
}Required, with an error
A prompt that can't be picked again; sending without a role marks the field invalid, points it at the message and moves the focus to it.
"use client"
import * as React from "react"
import { Button } from "@/components/ui/button"
import { Field, FieldDescription, FieldError, FieldLabel } from "@/components/ui/field"
import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select"
// A required select that starts on a prompt rather than a choice: the empty
// option is disabled and hidden from the list, so it can be shown but never
// picked again. Sending without a role marks the field invalid, points it at
// the message and moves the focus to it.
export default function NativeSelectInvalid() {
const id = React.useId()
const select = React.useRef<HTMLSelectElement>(null)
const [role, setRole] = React.useState("")
const [tried, setTried] = React.useState(false)
const invalid = tried && role === ""
return (
<form
noValidate
className="flex w-full max-w-xs flex-col gap-4"
onSubmit={(event) => {
event.preventDefault()
setTried(true)
if (role === "") select.current?.focus()
}}
>
<Field data-invalid={invalid || undefined}>
<FieldLabel htmlFor={`${id}-role`}>Role for Nadia Costa</FieldLabel>
<NativeSelect
ref={select}
id={`${id}-role`}
className="w-full"
value={role}
onChange={(event) => setRole(event.target.value)}
required
aria-invalid={invalid || undefined}
aria-describedby={invalid ? `${id}-error ${id}-hint` : `${id}-hint`}
>
<NativeSelectOption value="" disabled hidden>
Choose a role
</NativeSelectOption>
<NativeSelectOption value="admin">Admin</NativeSelectOption>
<NativeSelectOption value="member">Member</NativeSelectOption>
<NativeSelectOption value="viewer">Viewer</NativeSelectOption>
</NativeSelect>
{invalid ? (
<FieldError id={`${id}-error`} role={undefined}>
Choose what Nadia can do in the workspace.
</FieldError>
) : null}
<FieldDescription id={`${id}-hint`}>Viewers see every report but change nothing.</FieldDescription>
</Field>
<div className="flex items-center justify-between gap-3">
<p role="status" aria-live="polite" className="text-xs text-muted-foreground">
{tried && role !== "" ? "Invitation sent to nadia.costa@northwind.example." : null}
</p>
<Button type="submit" size="sm">
Send invite
</Button>
</div>
</form>
)
}Disabled, with the reason
A setting fixed at creation still shows its value, and the line under it says why and where it can change.
import { Label } from "@/components/ui/label"
import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select"
// A setting fixed at creation. The select still shows what it is set to,
// dimmed and out of the tab order, and the line under it says why it cannot
// change here and where it can.
export default function NativeSelectDisabled() {
return (
<div className="flex w-full max-w-xs flex-col gap-2">
<Label htmlFor="native-select-disabled-region">Data region</Label>
<NativeSelect
id="native-select-disabled-region"
className="w-full"
defaultValue="eu-central-1"
disabled
aria-describedby="native-select-disabled-why"
>
<NativeSelectOption value="eu-central-1">Frankfurt (eu-central-1)</NativeSelectOption>
</NativeSelect>
<p id="native-select-disabled-why" className="text-xs text-muted-foreground">
Chosen when Northwind Analytics was set up. Moving a workspace's data to another region takes a request
to support.
</p>
</div>
)
}A time range
Two selects read as a sentence, each labelled, in a fieldset named for the range; the line under them counts the hours across midnight.
"use client"
import * as React from "react"
import { MoonIcon } from "lucide-react"
import { FieldLegend, FieldSet } from "@/components/ui/field"
import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select"
const HOURS = Array.from({ length: 24 }, (_, hour) => hour)
const clock = (hour: number) => `${String(hour).padStart(2, "0")}:00`
// Two selects that make one range, read as a sentence: each has its own
// visible label, the fieldset names the pair, and the line under it says what
// the range comes to — across midnight included.
export default function NativeSelectTimeRange() {
const id = React.useId()
const [from, setFrom] = React.useState(22)
const [to, setTo] = React.useState(7)
const hours = (to - from + 24) % 24
return (
<FieldSet className="w-full max-w-xs gap-2" aria-describedby={`${id}-summary`}>
<FieldLegend variant="label" className="mb-0 flex items-center gap-2">
<MoonIcon aria-hidden="true" className="size-4 text-muted-foreground" />
Quiet hours
</FieldLegend>
<div className="flex items-center gap-2 text-sm">
<label htmlFor={`${id}-from`}>From</label>
<NativeSelect id={`${id}-from`} value={from} onChange={(event) => setFrom(Number(event.target.value))}>
{HOURS.map((hour) => (
<NativeSelectOption key={hour} value={hour}>
{clock(hour)}
</NativeSelectOption>
))}
</NativeSelect>
<label htmlFor={`${id}-to`}>to</label>
<NativeSelect id={`${id}-to`} value={to} onChange={(event) => setTo(Number(event.target.value))}>
{HOURS.map((hour) => (
<NativeSelectOption key={hour} value={hour}>
{clock(hour)}
</NativeSelectOption>
))}
</NativeSelect>
</div>
<p id={`${id}-summary`} aria-live="polite" className="text-xs text-muted-foreground">
{hours === 0
? "Start and end match, so alerts are never held back."
: `Alerts wait ${hours} ${hours === 1 ? "hour" : "hours"} each night, then arrive together.`}
</p>
</FieldSet>
)
}Dependent on another
The region follows the country — its label, its options — starts over when the country changes, and says why it waits.
"use client"
import * as React from "react"
import { Label } from "@/components/ui/label"
import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select"
const COUNTRIES: Record<string, { label: string; region: string; regions: string[] }> = {
AU: { label: "Australia", region: "State", regions: ["New South Wales", "Queensland", "Victoria"] },
CA: { label: "Canada", region: "Province", regions: ["British Columbia", "Ontario", "Quebec"] },
US: { label: "United States", region: "State", regions: ["California", "New York", "Texas", "Washington"] },
}
// The second select answers the first: its label and its options follow the
// country, it starts over when the country changes, and until there is a
// country it is disabled with the reason written under it.
export default function NativeSelectDependent() {
const id = React.useId()
const [country, setCountry] = React.useState("")
const [region, setRegion] = React.useState("")
const chosen = COUNTRIES[country]
return (
<div className="grid w-full max-w-sm gap-4 sm:grid-cols-2">
<div className="flex flex-col gap-2">
<Label htmlFor={`${id}-country`}>Billing country</Label>
<NativeSelect
id={`${id}-country`}
className="w-full"
value={country}
onChange={(event) => {
setCountry(event.target.value)
setRegion("")
}}
>
<NativeSelectOption value="" disabled hidden>
Choose a country
</NativeSelectOption>
{Object.entries(COUNTRIES).map(([code, entry]) => (
<NativeSelectOption key={code} value={code}>
{entry.label}
</NativeSelectOption>
))}
</NativeSelect>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor={`${id}-region`}>{chosen?.region ?? "State or province"}</Label>
<NativeSelect
id={`${id}-region`}
className="w-full"
value={region}
disabled={!chosen}
aria-describedby={`${id}-region-hint`}
onChange={(event) => setRegion(event.target.value)}
>
<NativeSelectOption value="" disabled hidden>
{chosen ? `Choose a ${chosen.region.toLowerCase()}` : "—"}
</NativeSelectOption>
{chosen?.regions.map((name) => (
<NativeSelectOption key={name} value={name}>
{name}
</NativeSelectOption>
))}
</NativeSelect>
<p id={`${id}-region-hint`} className="text-xs text-muted-foreground">
{chosen ? "Sets the sales tax on each invoice." : "Choose a country first."}
</p>
</div>
</div>
)
}Right to left
The demo's currency in Arabic: the value starts at the right, the chevron and the room for it keep the inline end.
import { Label } from "@/components/ui/label"
import { NativeSelect, NativeSelectOptGroup, NativeSelectOption } from "@/components/ui/native-select"
// The demo's currency in Arabic. The value starts at the right and the
// chevron keeps the inline end, on the left, with the room for it kept there
// too, so a long option never runs under it.
export default function NativeSelectRtl() {
return (
<div dir="rtl" lang="ar" className="flex w-full max-w-xs flex-col gap-2">
<Label htmlFor="native-select-rtl-currency">عملة التقارير</Label>
<NativeSelect id="native-select-rtl-currency" className="w-full" defaultValue="EUR">
<NativeSelectOptGroup label="الأكثر استخدامًا">
<NativeSelectOption value="EUR">يورو (EUR)</NativeSelectOption>
<NativeSelectOption value="USD">دولار أمريكي (USD)</NativeSelectOption>
<NativeSelectOption value="GBP">جنيه إسترليني (GBP)</NativeSelectOption>
</NativeSelectOptGroup>
<NativeSelectOptGroup label="عملات أخرى">
<NativeSelectOption value="AED">درهم إماراتي (AED)</NativeSelectOption>
<NativeSelectOption value="SAR">ريال سعودي (SAR)</NativeSelectOption>
<NativeSelectOption value="CHF">فرنك سويسري (CHF)</NativeSelectOption>
</NativeSelectOptGroup>
</NativeSelect>
</div>
)
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| size | "sm" | "default" | "default" | 32px tall, or 28px for a toolbar. |
| className | string | — | Styles the wrapper, not the select: give the width there (w-full) and the select fills it. |
| disabled | boolean | false | Dims the field and takes it out of the tab order, so write why beside it. |
| NativeSelectOption.disabled + hidden | boolean | — | On an empty first option (value=""), a prompt that shows until something is chosen and can never be picked again. |
Dependencies
Registry
npm
Source
import * as React from "react"
import { cn } from "@/lib/utils"
import { ChevronDownIcon } from "lucide-react"
type NativeSelectProps = Omit<React.ComponentProps<"select">, "size"> & {
size?: "sm" | "default"
}
function NativeSelect({
className,
size = "default",
...props
}: NativeSelectProps) {
return (
<div
className={cn(
"group/native-select relative w-fit has-[select:disabled]:opacity-50",
className
)}
data-slot="native-select-wrapper"
data-size={size}
>
{/* On the card plane in both themes, as Input and the Select trigger are,
so a form's fields stand on one surface side by side. */}
<select
data-slot="native-select"
data-size={size}
className="h-8 w-full min-w-0 appearance-none rounded-lg border border-input bg-card py-1 ps-2.5 pe-8 text-sm transition-colors select-none focus-ring selection:bg-primary selection:text-primary-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] data-[size=sm]:py-0.5 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40"
{...props}
/>
<ChevronDownIcon className="pointer-events-none absolute top-1/2 end-2.5 size-4 -translate-y-1/2 text-muted-foreground select-none" aria-hidden="true" data-slot="native-select-icon" />
</div>
)
}
function NativeSelectOption({
className,
...props
}: React.ComponentProps<"option">) {
return (
<option
data-slot="native-select-option"
className={cn("bg-[Canvas] text-[CanvasText]", className)}
{...props}
/>
)
}
function NativeSelectOptGroup({
className,
...props
}: React.ComponentProps<"optgroup">) {
return (
<optgroup
data-slot="native-select-optgroup"
className={cn("bg-[Canvas] text-[CanvasText]", className)}
{...props}
/>
)
}
export { NativeSelect, NativeSelectOptGroup, NativeSelectOption }