The browser's own select, styled to match the input: three sizes, a placeholder, groups, a leading icon, invalid and disabled states and a loading spinner, so phones keep their picker and forms keep working.
import { NativeSelect, NativeSelectOption } from "@/components/ballmac/native-select"
export default function NativeSelectDemo() {
return (
<div className="grid w-full max-w-xs gap-1.5">
<label htmlFor="country" className="text-sm font-medium">
Country
</label>
<NativeSelect id="country" name="country" placeholder="Choose a country" defaultValue="">
<NativeSelectOption value="pt">Portugal</NativeSelectOption>
<NativeSelectOption value="jp">Japan</NativeSelectOption>
<NativeSelectOption value="ke">Kenya</NativeSelectOption>
<NativeSelectOption value="ca">Canada</NativeSelectOption>
<NativeSelectOption value="fi">Finland</NativeSelectOption>
</NativeSelect>
</div>
)
}Installation
$ pnpm dlx shadcn@latest add @ballmac/native-selectInstall the dependencies.
$ pnpm add class-variance-authority lucide-reactCopy the source into your project.
components/ballmac/native-select.tsx// Ballmac UI: Native Select. https://ui.ballmac.com/components/native-select // Based on shadcn/ui's Input (MIT, Copyright (c) 2023 shadcn) for the look, wrapped around the browser's own select. "use client" import * as React from "react" import { cva, type VariantProps } from "class-variance-authority" import { ChevronDown, Loader2 } from "lucide-react" import { cn } from "@/lib/utils" const selectVariants = cva( "peer w-full min-w-0 appearance-none rounded-md border border-input bg-background text-foreground shadow-xs outline-none transition-[color,border-color,box-shadow] duration-150 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:bg-input/30 motion-reduce:transition-none", { variants: { size: { sm: "h-[var(--bm-control-h-sm,calc(var(--spacing)*8))] ps-2.5 text-[13px]", default: "h-[var(--bm-control-h,calc(var(--spacing)*9))] ps-3 text-sm", lg: "h-[var(--bm-control-h-lg,calc(var(--spacing)*11))] ps-4 text-[15px]", }, // a list box has no arrow, so it needs no room for one listbox: { false: "cursor-pointer pe-9", true: "h-auto py-1 pe-2", }, }, defaultVariants: { size: "default", listbox: false }, } ) type NativeSelectProps = Omit<React.ComponentProps<"select">, "size"> & Pick<VariantProps<typeof selectVariants>, "size"> & { /** Called with the chosen value, as well as the usual `onChange`. */ onValueChange?: (value: string) => void /** Drawn before the text, such as an icon. Keep it decorative. */ startIcon?: React.ReactNode /** Shows a spinner in place of the arrow and marks the field busy, while the options load. The field stays usable. */ loading?: boolean /** Text of a first, empty choice that asks for a selection. It is not offered once something is chosen. */ placeholder?: string /** Lines to show for a `multiple` select. */ rows?: number } /** * The browser's own select, dressed to match the rest of the form. Because it is the real element, it keeps what people expect: the phone's picker, * type to jump, form submission, autofill and every screen reader. It takes the same sizes and states as the input. */ function NativeSelect({ className, size = "default", onValueChange, onChange, startIcon, loading = false, placeholder, rows, multiple, children, defaultValue, value, style, ...props }: NativeSelectProps) { const listbox = !!multiple // with a placeholder, an untouched select starts on it const initial = value === undefined && defaultValue === undefined && placeholder ? "" : defaultValue return ( <div data-slot="native-select" data-size={size} className={cn("relative w-full min-w-0", className)}> {startIcon && !listbox ? ( <span aria-hidden="true" className="pointer-events-none absolute inset-y-0 start-3 flex items-center text-muted-foreground peer-disabled:opacity-50 [&_svg]:size-4"> {startIcon} </span> ) : null} <select data-slot="native-select-field" multiple={multiple} size={listbox ? (rows ?? 4) : undefined} aria-busy={loading || undefined} value={value} defaultValue={initial} onChange={(e) => { onChange?.(e) onValueChange?.(multiple ? Array.from(e.target.selectedOptions, (o) => o.value).join(",") : e.target.value) }} className={cn(selectVariants({ size, listbox }), startIcon && !listbox && "ps-9", "invalid:text-muted-foreground [&:has(option[value='']:checked)]:text-muted-foreground")} style={style} {...props} > {placeholder ? ( <option value="" disabled={props.required}> {placeholder} </option> ) : null} {children} </select> {!listbox ? ( <span aria-hidden="true" className="pointer-events-none absolute inset-y-0 end-3 flex items-center text-muted-foreground peer-disabled:opacity-50"> {loading ? <Loader2 className="size-4 animate-spin motion-reduce:animate-none" /> : <ChevronDown className="size-4" />} </span> ) : null} </div> ) } /** One choice. The browser draws it, so only what a native option can do applies: text, a value and `disabled`. */ function NativeSelectOption({ className, ...props }: React.ComponentProps<"option">) { return <option data-slot="native-select-option" className={cn("bg-popover text-popover-foreground", className)} {...props} /> } /** A heading over a set of choices. */ function NativeSelectGroup({ className, ...props }: React.ComponentProps<"optgroup">) { return <optgroup data-slot="native-select-group" className={cn("bg-popover font-medium text-muted-foreground", className)} {...props} /> } export { NativeSelect, NativeSelectGroup, NativeSelectOption, selectVariants, type NativeSelectProps }Update the import paths to match your project setup.
Usage
import { NativeSelect, NativeSelectGroup, NativeSelectOption } from "@/components/ballmac/native-select"The full example is in the Code tab above.
Examples
Groups and an icon
import { Globe } from "lucide-react"
import { NativeSelect, NativeSelectGroup, NativeSelectOption } from "@/components/ballmac/native-select"
export default function NativeSelectGroups() {
return (
<div className="grid w-full max-w-xs gap-1.5">
<label htmlFor="tz" className="text-sm font-medium">
Time zone
</label>
<NativeSelect id="tz" startIcon={<Globe />} defaultValue="Europe/Lisbon">
<NativeSelectGroup label="Europe">
<NativeSelectOption value="Europe/Lisbon">Lisbon (WET)</NativeSelectOption>
<NativeSelectOption value="Europe/Helsinki">Helsinki (EET)</NativeSelectOption>
</NativeSelectGroup>
<NativeSelectGroup label="Asia">
<NativeSelectOption value="Asia/Tokyo">Tokyo (JST)</NativeSelectOption>
<NativeSelectOption value="Asia/Kolkata">Kolkata (IST)</NativeSelectOption>
</NativeSelectGroup>
<NativeSelectGroup label="Africa">
<NativeSelectOption value="Africa/Nairobi">Nairobi (EAT)</NativeSelectOption>
</NativeSelectGroup>
</NativeSelect>
</div>
)
}Sizes
"use client"
import { NativeSelect, NativeSelectOption } from "@/components/ballmac/native-select"
export default function NativeSelectSizes() {
return (
<div className="grid w-full max-w-xs gap-3">
{(["sm", "default", "lg"] as const).map((size) => (
<NativeSelect key={size} size={size} aria-label={`Plan, ${size}`} defaultValue="team">
<NativeSelectOption value="free">Free</NativeSelectOption>
<NativeSelectOption value="team">Team</NativeSelectOption>
<NativeSelectOption value="business">Business</NativeSelectOption>
</NativeSelect>
))}
</div>
)
}In a form, with an error
"use client"
import * as React from "react"
import { Button } from "@/components/ballmac/button"
import { NativeSelect, NativeSelectOption } from "@/components/ballmac/native-select"
export default function NativeSelectForm() {
const [value, setValue] = React.useState("")
const [touched, setTouched] = React.useState(false)
const invalid = touched && value === ""
return (
<form
noValidate
onSubmit={(e) => {
e.preventDefault()
setTouched(true)
}}
className="grid w-full max-w-xs gap-3"
>
<div className="grid gap-1.5">
<label htmlFor="role" className="text-sm font-medium">
Your role
</label>
<NativeSelect id="role" name="role" required placeholder="Select a role" value={value} onValueChange={setValue} aria-invalid={invalid || undefined} aria-describedby="role-hint">
<NativeSelectOption value="design">Design</NativeSelectOption>
<NativeSelectOption value="eng">Engineering</NativeSelectOption>
<NativeSelectOption value="ops">Operations</NativeSelectOption>
</NativeSelect>
<p id="role-hint" className={invalid ? "text-xs text-destructive" : "text-xs text-muted-foreground"}>
{invalid ? "Choose the role that fits you best." : "It decides which tips you see first."}
</p>
</div>
<Button type="submit">Continue</Button>
</form>
)
}Options still loading
"use client"
import * as React from "react"
import { NativeSelect, NativeSelectOption } from "@/components/ballmac/native-select"
/** The options arrive after a moment; the field shows a spinner and says it is busy, and can still be opened. */
export default function NativeSelectLoading() {
const [cities, setCities] = React.useState<string[] | null>(null)
React.useEffect(() => {
const t = setTimeout(() => setCities(["Lisbon", "Osaka", "Nairobi", "Toronto"]), 2500)
return () => clearTimeout(t)
}, [])
return (
<div className="grid w-full max-w-xs gap-1.5">
<label htmlFor="city" className="text-sm font-medium">
City
</label>
<NativeSelect id="city" loading={cities === null} placeholder={cities ? "Choose a city" : "Loading cities"} defaultValue="">
{cities?.map((c) => (
<NativeSelectOption key={c} value={c}>
{c}
</NativeSelectOption>
))}
</NativeSelect>
</div>
)
}Disabled, invalid and multiple
import { NativeSelect, NativeSelectOption } from "@/components/ballmac/native-select"
export default function NativeSelectDisabled() {
return (
<div className="grid w-full max-w-xs gap-3">
<NativeSelect aria-label="Billing period, disabled" disabled defaultValue="yearly">
<NativeSelectOption value="monthly">Monthly</NativeSelectOption>
<NativeSelectOption value="yearly">Yearly</NativeSelectOption>
</NativeSelect>
<NativeSelect aria-label="Currency, invalid" aria-invalid="true" defaultValue="">
<NativeSelectOption value="" disabled>
Choose a currency
</NativeSelectOption>
<NativeSelectOption value="usd">US dollar</NativeSelectOption>
<NativeSelectOption value="eur">Euro</NativeSelectOption>
</NativeSelect>
<NativeSelect aria-label="Languages" multiple rows={3} defaultValue={["en", "pt"]}>
<NativeSelectOption value="en">English</NativeSelectOption>
<NativeSelectOption value="pt">Portuguese</NativeSelectOption>
<NativeSelectOption value="ja">Japanese</NativeSelectOption>
<NativeSelectOption value="sw">Swahili</NativeSelectOption>
</NativeSelect>
</div>
)
}API reference
| Prop | Type | Default |
|---|---|---|
onValueChangeCalled with the chosen value, as well as the usual `onChange`. | (value: string) => void | — |
startIconDrawn before the text, such as an icon. Keep it decorative. | React.ReactNode | — |
loadingShows a spinner in place of the arrow and marks the field busy, while the options load. The field stays usable. | boolean | false |
placeholderText of a first, empty choice that asks for a selection. It is not offered once something is chosen. | string | — |
rowsLines to show for a `multiple` select. | number | — |
size | "sm" | "default" | "lg" | "default" |
listbox | "false" | "true" | false |
Also accepts the standard attributes of its root element.
Accessibility
| Key | Action |
|---|---|
| Tab / Shift+Tab | Moves to and from the field |
| Space / Alt+Down / Enter | Opens the browser's list; arrow keys and typing choose |
| Screen readers | It is a real select, so name, value, options and groups are read by the browser; give it a label |
| aria-invalid and aria-describedby | Set them with an error message, as on an input |
Quality scorecard
Every applicable check passes
Measured in Chromium 153.0.8010.12 on October 19, 2026, on version 1.0.0, across 6 examples.
Passed: Accessibility scan
No serious or critical issues in light and dark, on a phone and a desktop (axe-core 4.13.0, WCAG 2.1 AA and best-practice rules).
Passed: Small screens
No sideways scrolling at 360, 390 or 1280 px wide.
Passed: Reduced motion
Every endless animation stops when the visitor asks for less motion.
Passed: Right to left
Mirrors correctly in Arabic, Hebrew, Persian and Urdu layouts (0% of elements off the mirror, limit 15%).
Passed: Clean console
No console errors or React hydration errors while loading.
Passed: Keyboard
10 tab stops, each showing a visible change when it takes focus. 4 documented keys.
Passed: Loading, empty and error
Ships loading, disabled states, each with an example.
Passed: Automated tests
Covered by native-select.test.tsx.
For information: Footprint
2.0 KB of source (gzip) in 1 file. Installs class-variance-authority, lucide-react.
Automated checks find a subset of problems and do not replace testing with a screen reader and a keyboard in your own app. What they miss.
Use with AI
<NativeSelect name placeholder> with <NativeSelectOption value> children and <NativeSelectGroup label> for groups. It is a real select: use value and onValueChange, or leave it uncontrolled in a form. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Native Select (@ballmac/native-select) to this project with the shadcn MCP, then use it where it fits.
Use it for
- Forms where a short list of choices should use the device's own picker (phones, tablets)
- Long lists such as countries or time zones where type-to-jump matters
- Anywhere accessibility and autofill should be exactly the browser's
Not for
- Options with icons, descriptions or search; use select or combobox
- Choosing many items with chips; use multi-select
- A handful of choices that deserve to be seen at once; use radio-group or segmented-control
Registry JSON: https://ui.ballmac.com/r/native-select.json
Credits
Based on shadcn/ui Input, adapted by Ballmac. Free to use in personal and commercial projects.
- Registry
- shadcn/utils
Pairs well with
Label
An accessible form label built on Radix Label that dims itself when the paired peer control is disabled and never selects text on double-click.
Input
A text input in three heights that match Button, plus InputGroup and InputGroupAddon for leading or trailing icons, units and domains.
Field
Form field layout with label, description and error that wire their ids to the control automatically, plus fieldset, legend, orientation and invalid/disabled state.
Calendar
A date grid for one day, several days or a range, with month and year selects, week numbers, disabled rules and range-end styling, on React DayPicker.
Ballmac UI Pro
Need the whole screen, not one piece?
150 premium blocks (dashboards, settings, calendars, mail, shops, onboarding), a private shadcn registry and Figma tokens. Blocks that show data come with loading, empty and error states built in.
Not ready? Try three Pro blocks free
Leave your email and the blocks appear right here, ready to copy. Yours to use in any project.