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.jsonAdds the component, the HextaUI theme tokens and any HextaUI components it depends on.
- 1
Add the theme tokens to your global CSS, if you haven’t yet.
- 2
Install the dependencies.
pnpm add @base-ui/react @tabler/icons-react class-variance-authority cn - 3
Copy and paste the following code into your project.
components/ui/native-select.tsx "use client" import * as React from "react" import { Input as InputPrimitive } from "@base-ui/react/input" import { IconSelector } from "@tabler/icons-react" import { cn } from "cn" import { useComposedRef } from "@/hooks/use-composed-ref" import { useInvalidShake } from "@/hooks/use-invalid-shake" import { inputVariants } from "@/components/ui/input" type NativeSelectSize = "sm" | "default" | "lg" type NativeSelectProps = Omit<React.ComponentProps<"select">, "size"> & { size?: NativeSelectSize htmlSize?: number shake?: boolean } function NativeSelect({ className, size = "default", htmlSize, shake = true, ref, ...props }: NativeSelectProps) { const [selectRef, setRef] = useComposedRef<HTMLSelectElement>(ref) useInvalidShake(selectRef, shake) return ( <div data-slot="native-select-wrapper" data-size={size} className={cn( "group/native-select relative w-fit min-w-0 has-[select:disabled]:opacity-50", className )} > <InputPrimitive data-slot="native-select" data-size={size} className={cn( inputVariants({ size }), "cursor-pointer appearance-none truncate pe-8 disabled:opacity-100 has-[option[value='']:checked]:text-muted-foreground dark:scheme-dark pointer-coarse:pe-9 [&_optgroup]:bg-[Canvas] [&_optgroup]:text-[CanvasText] [&_option]:bg-[Canvas] [&_option]:text-[CanvasText]" )} render={<select ref={setRef} size={htmlSize} />} {...(props as InputPrimitive.Props)} /> <IconSelector aria-hidden="true" data-slot="native-select-icon" className="pointer-events-none absolute end-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground transition-colors duration-150 group-has-[select:focus-visible]/native-select:text-foreground motion-reduce:transition-none pointer-coarse:end-3 [@media(hover:hover)]:group-hover/native-select:not-group-has-[select:disabled]/native-select:text-foreground" /> </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 } export type { NativeSelectProps, NativeSelectSize }components/ui/input.tsx "use client" import * as React from "react" import { Input as InputPrimitive } from "@base-ui/react/input" import { cva, type VariantProps } from "class-variance-authority" import { cn } from "cn" import { useAutosize } from "@/hooks/use-autosize" import { useComposedRef } from "@/hooks/use-composed-ref" import { useInvalidShake } from "@/hooks/use-invalid-shake" import { useMergedRef } from "@/hooks/use-merged-ref" import { prefersReducedMotion } from "@/lib/motion" import { NumberFlow } from "@/components/ui/number-flow" type ClassName<State> = string | ((state: State) => string | undefined) | undefined function mergeClassName<State>(base: string, className: ClassName<State>) { return typeof className === "function" ? (state: State) => cn(base, className(state)) : cn(base, className) } const inputVariants = cva( "w-full min-w-0 rounded-md bg-transparent text-sm text-foreground inset-ring-(length:--hairline) inset-ring-input transition-[color,background-color,box-shadow] duration-150 ease-out-cubic outline-none selection:bg-primary selection:text-primary-foreground file:me-3 file:inline-flex file:h-full file:items-center file:border-0 file:bg-transparent file:p-0 file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground placeholder:transition-opacity placeholder:duration-150 user-invalid:inset-ring-destructive focus-visible:ring-3 focus-visible:ring-focus-ring focus-visible:inset-ring-ring focus-visible:outline-hidden focus-visible:placeholder:opacity-70 user-invalid:focus-visible:ring-destructive/70 user-invalid:focus-visible:inset-ring-destructive disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:inset-ring-destructive aria-invalid:focus-visible:ring-destructive/70 aria-invalid:focus-visible:inset-ring-destructive data-invalid:inset-ring-destructive data-invalid:focus-visible:ring-destructive/70 data-invalid:focus-visible:inset-ring-destructive data-shake:motion-safe:animate-button-shake motion-reduce:transition-none dark:bg-input/30 dark:scheme-dark dark:user-invalid:focus-visible:ring-destructive/70 dark:aria-invalid:focus-visible:ring-destructive/70 dark:data-invalid:focus-visible:ring-destructive/70 forced-colors:border pointer-coarse:text-[max(16px,1rem)] [&::-webkit-search-cancel-button]:cursor-pointer [&::-webkit-search-cancel-button]:opacity-50 [&::-webkit-search-cancel-button]:transition-opacity [&::-webkit-search-cancel-button:hover]:opacity-100 [&[readonly]]:bg-muted/40 dark:[&[readonly]]:bg-input/20 [@media(hover:hover)]:hover:not-focus-visible:not-disabled:not-[[readonly]]:not-aria-invalid:not-data-invalid:not-user-invalid:inset-ring-ring/70", { variants: { size: { sm: "h-8 px-2.5 file:pe-2.5", default: "h-9 px-3", lg: "h-10 px-3.5", }, }, defaultVariants: { size: "default", }, } ) type InputControl = HTMLInputElement | HTMLTextAreaElement type InputProps = Omit<InputPrimitive.Props, "size"> & VariantProps<typeof inputVariants> & { htmlSize?: number shake?: boolean } function Input({ className, size = "default", htmlSize, shake = true, ref, ...props }: InputProps) { const resolvedSize = size ?? "default" const [inputRef, setRef] = useComposedRef<HTMLInputElement>(ref) useInvalidShake(inputRef, shake) return ( <InputPrimitive ref={setRef} data-slot="input" data-size={resolvedSize} size={htmlSize} className={mergeClassName( inputVariants({ size: resolvedSize }), className )} {...props} /> ) } function defaultAnnouncement(remaining: number) { return remaining === 0 ? "Character limit reached" : `${remaining} ${remaining === 1 ? "character" : "characters"} left` } type InputCountProps = Omit<React.ComponentProps<"span">, "children"> & { length: number maxLength?: number | null controlRef?: React.RefObject<InputControl | null> threshold?: number announcement?: (remaining: number) => string } function InputCount({ className, length, maxLength = null, controlRef, threshold, announcement = defaultAnnouncement, ref, ...props }: InputCountProps) { const max = maxLength !== null && maxLength > 0 ? maxLength : null const remaining = max === null ? null : Math.max(0, max - length) const near = max === null ? Infinity : Math.max(1, threshold ?? Math.ceil(Math.min(max * 0.1, 20))) const state = remaining === null ? undefined : remaining === 0 ? "limit" : remaining <= near ? "near" : undefined const [message, setMessage] = React.useState("") const [lastState, setLastState] = React.useState(state) if (state !== lastState) { setLastState(state) setMessage(state && remaining !== null ? announcement(remaining) : "") } const [countRef, setCountRef] = useComposedRef<HTMLSpanElement>(ref) React.useEffect(() => { const element = controlRef?.current const count = countRef.current if (!element || !count || max === null) { return } let timer: ReturnType<typeof setTimeout> | undefined const onBeforeInput = (event: Event) => { const input = event as InputEvent if ( !input.inputType?.startsWith("insert") || element.value.length < max || element.selectionStart !== element.selectionEnd || prefersReducedMotion() ) { return } count.removeAttribute("data-bump") void count.offsetWidth count.setAttribute("data-bump", "") clearTimeout(timer) timer = setTimeout(() => count.removeAttribute("data-bump"), 400) } element.addEventListener("beforeinput", onBeforeInput) return () => { element.removeEventListener("beforeinput", onBeforeInput) clearTimeout(timer) } }, [controlRef, max]) return ( <span ref={setCountRef} data-slot="input-count" data-state={state} className={cn( "inline-flex shrink-0 items-baseline text-xs text-muted-foreground tabular-nums transition-colors duration-200 ease-out-cubic data-bump:animate-button-shake data-[state=limit]:text-destructive data-[state=near]:text-foreground", className )} {...props} > <span aria-hidden="true" dir="ltr" className="inline-flex items-baseline"> <NumberFlow value={length} /> {max !== null && <span>/{max}</span>} </span> <span role="status" className="sr-only"> {message} </span> </span> ) } export { Input, InputCount, useAutosize, inputVariants, useComposedRef, useInvalidShake, useMergedRef, } export type { InputControl, InputCountProps, InputProps }components/ui/number-flow.tsx "use client" import * as React from "react" import { cn } from "cn" import { easeSpring } from "@/lib/motion" type NumberFlowTrend = "auto" | "up" | "down" | "shortest" type NumberFlowChar = { key: string section: number order: number value: string digit: number | null } type NumberFlowItem = NumberFlowChar & { exiting: boolean exitId: number } type NumberFlowProps = Omit< React.ComponentProps<"span">, "children" | "prefix" > & { value: number locales?: Intl.LocalesArgument format?: Intl.NumberFormatOptions prefix?: string suffix?: string trend?: NumberFlowTrend duration?: number easing?: string animated?: boolean onAnimationsStart?: () => void onAnimationsFinish?: () => void } const rows = 30 const restingClasses = "data-[digit='0']:translate-y-[-33.3333%] data-[digit='1']:translate-y-[-36.6667%] data-[digit='2']:translate-y-[-40%] data-[digit='3']:translate-y-[-43.3333%] data-[digit='4']:translate-y-[-46.6667%] data-[digit='5']:translate-y-[-50%] data-[digit='6']:translate-y-[-53.3333%] data-[digit='7']:translate-y-[-56.6667%] data-[digit='8']:translate-y-[-60%] data-[digit='9']:translate-y-[-63.3333%]" function getSpinDelta( from: number, to: number, trend: Exclude<NumberFlowTrend, "auto"> ) { let delta = to - from if (trend === "up") { if (delta < 0) { delta += 10 } } else if (trend === "down") { if (delta > 0) { delta -= 10 } } else if (delta > 5) { delta -= 10 } else if (delta < -4) { delta += 10 } return delta } function digitGlyphs( formatter: Intl.NumberFormat, locales: Intl.LocalesArgument ) { const { numberingSystem } = formatter.resolvedOptions() const plain = new Intl.NumberFormat(locales, { numberingSystem, useGrouping: false, }) return Array.from({ length: 10 }, (_, digit) => plain.format(digit)) } function formatChars( value: number, locales: Intl.LocalesArgument, format: Intl.NumberFormatOptions | undefined, prefix: string | undefined, suffix: string | undefined ) { const formatter = new Intl.NumberFormat(locales, format) const glyphs = digitGlyphs(formatter, locales) const parts = formatter.formatToParts(value) const integerCount = parts .filter((part) => part.type === "integer") .reduce((count, part) => count + Array.from(part.value).length, 0) const chars: NumberFlowChar[] = [] const occurrences = new Map<string, number>() let integerIndex = 0 let fractionIndex = 0 let seenNumber = false function symbol(section: number, type: string, text: string) { const name = `${section}-${type}` const occurrence = occurrences.get(name) ?? 0 occurrences.set(name, occurrence + 1) chars.push({ key: `s${name}-${occurrence}`, section, order: occurrence, value: text, digit: null, }) } if (prefix) { chars.push({ key: "prefix", section: 0, order: 0, value: prefix, digit: null, }) } for (const part of parts) { if (part.type === "integer") { seenNumber = true for (const glyph of Array.from(part.value)) { const place = integerCount - 1 - integerIndex const digit = glyphs.indexOf(glyph) chars.push({ key: `i${place}`, section: 2, order: -place, value: glyph, digit: digit === -1 ? null : digit, }) integerIndex += 1 } } else if (part.type === "group") { const place = integerCount - 1 - integerIndex chars.push({ key: `g${place}`, section: 2, order: -place - 0.5, value: part.value, digit: null, }) } else if (part.type === "decimal") { seenNumber = true chars.push({ key: "decimal", section: 3, order: 0, value: part.value, digit: null, }) } else if (part.type === "fraction") { for (const glyph of Array.from(part.value)) { const digit = glyphs.indexOf(glyph) chars.push({ key: `f${fractionIndex}`, section: 4, order: fractionIndex, value: glyph, digit: digit === -1 ? null : digit, }) fractionIndex += 1 } } else { symbol(seenNumber ? 5 : 1, part.type, part.value) } } if (suffix) { chars.push({ key: "suffix", section: 6, order: 0, value: suffix, digit: null, }) } return chars } function signature(chars: NumberFlowChar[]) { return chars.map((char) => `${char.key}:${char.value}`).join("|") } function mergeItems(previous: NumberFlowItem[], next: NumberFlowChar[]) { const keys = new Set(next.map((char) => char.key)) const kept = previous .filter((item) => !keys.has(item.key)) .map((item) => item.exiting ? item : { ...item, exiting: true, exitId: item.exitId + 1 } ) const fresh = next.map((char) => { const before = previous.find((item) => item.key === char.key) return { ...char, exiting: false, exitId: before?.exitId ?? 0 } }) return [...kept, ...fresh].sort( (a, b) => a.section - b.section || a.order - b.order ) } function subscribeReducedMotion(callback: () => void) { if (typeof window.matchMedia !== "function") { return () => {} } const query = window.matchMedia("(prefers-reduced-motion: reduce)") query.addEventListener("change", callback) return () => query.removeEventListener("change", callback) } function getReducedMotion() { return ( typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches ) } function canAnimate() { return ( typeof Element !== "undefined" && typeof Element.prototype.animate === "function" ) } function liveIndex(column: HTMLElement) { const height = column.offsetHeight if (!height) { return null } const translate = getComputedStyle(column).translate const y = translate === "none" ? "0" : (translate.split(" ")[1] ?? "0") const amount = parseFloat(y) || 0 return y.endsWith("%") ? (-amount / 100) * rows : (-amount / height) * rows } function NumberFlow({ value, locales = "en-US", format, prefix, suffix, trend = "auto", duration = 600, easing = easeSpring, animated = true, onAnimationsStart, onAnimationsFinish, className, ...props }: NumberFlowProps) { const chars = formatChars(value, locales, format, prefix, suffix) const key = signature(chars) const reducedMotion = React.useSyncExternalStore( subscribeReducedMotion, getReducedMotion, () => false ) const animate = animated && !reducedMotion && canAnimate() const [state, setState] = React.useState(() => ({ key, items: chars.map((char) => ({ ...char, exiting: false, exitId: 0 })), })) if (state.key !== key) { setState({ key, items: animate ? mergeItems(state.items, chars) : chars.map((char) => ({ ...char, exiting: false, exitId: 0 })), }) } const rootRef = React.useRef<HTMLSpanElement>(null) const previousRef = React.useRef<Map< string, { digit: number | null; exiting: boolean; exitId: number } > | null>(null) const valueRef = React.useRef(value) const batchRef = React.useRef(0) const callbacksRef = React.useRef({ onAnimationsStart, onAnimationsFinish }) const finishExitRef = React.useRef((itemKey: string, exitId: number) => { setState((current) => ({ ...current, items: current.items.filter( (item) => !(item.key === itemKey && item.exiting && item.exitId === exitId) ), })) }) React.useLayoutEffect(() => { callbacksRef.current = { onAnimationsStart, onAnimationsFinish } }) React.useLayoutEffect(() => { const root = rootRef.current const previous = previousRef.current const current = new Map( state.items.map((item) => [ item.key, { digit: item.digit, exiting: item.exiting, exitId: item.exitId }, ]) ) const previousValue = valueRef.current previousRef.current = current valueRef.current = value if (!root || !previous || !animate) { return } const direction = trend !== "auto" ? trend : value > previousValue ? "up" : value < previousValue ? "down" : "shortest" const timing = { duration, easing } const started: Animation[] = [] const cells = root.querySelectorAll<HTMLElement>( ":scope > [data-number-flow-key]" ) for (const cell of cells) { const itemKey = cell.dataset.numberFlowKey! const item = current.get(itemKey) const before = previous.get(itemKey) if (!item || typeof cell.animate !== "function") { continue } const column = cell.querySelector<HTMLElement>( "[data-slot=number-flow-column]" ) const entering = !item.exiting && (!before || before.exiting) const leaving = item.exiting && (!before || !before.exiting) if (entering || leaving) { const running = cell.getAnimations() const fromWidth = running.length ? cell.getBoundingClientRect().width : entering ? 0 : cell.getBoundingClientRect().width const fromOpacity = running.length ? Number(getComputedStyle(cell).opacity) : entering ? 0 : 1 running.forEach((animation) => animation.cancel()) const toWidth = entering ? cell.getBoundingClientRect().width : 0 const animation = cell.animate( [ { width: `${fromWidth}px`, opacity: fromOpacity }, { width: `${toWidth}px`, opacity: entering ? 1 : 0 }, ], { ...timing, fill: leaving ? "forwards" : "none" } ) started.push(animation) if (leaving) { const exitId = item.exitId animation.finished.then( () => finishExitRef.current(itemKey, exitId), () => {} ) } } if (!column || item.digit === null || item.exiting) { continue } const target = item.digit const running = column.getAnimations() const live = running.length ? liveIndex(column) : null running.forEach((animation) => animation.cancel()) const from = live !== null ? live - 10 : entering ? 0 : before && before.digit !== null ? before.digit : target if (live === null && from === target && !entering) { continue } const delta = getSpinDelta( from, target, entering && live === null ? "up" : direction ) if (delta === 0) { continue } started.push( column.animate( [ { translate: `0 ${(-(target + 10 - delta) / rows) * 100}%` }, { translate: `0 ${(-(target + 10) / rows) * 100}%` }, ], timing ) ) } if (!started.length) { return } batchRef.current += 1 const batch = batchRef.current callbacksRef.current.onAnimationsStart?.() Promise.all(started.map((animation) => animation.finished)).then( () => { if (batch === batchRef.current) { callbacksRef.current.onAnimationsFinish?.() } }, () => {} ) }, [state.items, animate, duration, easing, trend, value]) return ( <span ref={rootRef} dir="ltr" className={cn("inline-block max-w-full tabular-nums", className)} {...props} data-slot="number-flow" > <span data-slot="number-flow-text" className="sr-only"> {state.items .filter((item) => !item.exiting) .map((item) => item.value) .join("")} </span> {state.items.map((item) => item.digit === null ? ( <span key={item.key} data-number-flow-key={item.key} data-slot="number-flow-symbol" aria-hidden className="inline-block max-w-full overflow-clip wrap-anywhere whitespace-pre-wrap" > {item.value} </span> ) : ( <span key={item.key} data-number-flow-key={item.key} data-slot="number-flow-digit" aria-hidden className="relative -my-[0.2em] inline-block overflow-clip [mask-image:linear-gradient(to_bottom,transparent,black_0.2em,black_calc(100%-0.2em),transparent)] py-[0.2em]" > <span className="opacity-0">{item.value}</span> <span aria-hidden data-slot="number-flow-column" data-digit={item.digit} className={cn( "pointer-events-none absolute inset-x-0 top-0 flex flex-col items-center select-none", restingClasses )} > {Array.from({ length: rows }, (_, row) => ( <span key={row} className="block py-[0.2em]"> {glyphFor(item, row)} </span> ))} </span> </span> ) )} </span> ) } function glyphFor(item: NumberFlowItem, row: number) { return String.fromCodePoint( item.value.codePointAt(0)! - (item.digit ?? 0) + (row % 10) ) } export { NumberFlow, getSpinDelta, formatChars, type NumberFlowProps, type NumberFlowTrend, }lib/motion.ts import * as React from "react" const easeOut = "cubic-bezier(0.23, 1, 0.32, 1)" const easeInOut = "cubic-bezier(0.77, 0, 0.175, 1)" const easeSpring = "linear(0, 0.015 2%, 0.0532 4%, 0.1065 6%, 0.1686 8%, 0.2351 10%, 0.3363 13%, 0.4332 16%, 0.5495 20%, 0.648 24%, 0.7287 28%, 0.807 33%, 0.8646 38%, 0.9128 44%, 0.9447 50%, 0.968 57%, 0.9834 65%, 0.9924 74%, 0.9975 85%, 1)" const duration = { press: 100, release: 200, hover: 150, enter: 200, exit: 150, morph: 300, } as const function prefersReducedMotion() { return ( typeof window === "undefined" || typeof window.matchMedia !== "function" || window.matchMedia("(prefers-reduced-motion: reduce)").matches ) } type SizeAxis = "width" | "height" type SizeMorphOptions = { axis: SizeAxis enabled?: boolean duration?: number easing?: string } function readSize(element: HTMLElement, axis: SizeAxis) { const value = parseFloat(getComputedStyle(element)[axis]) if (Number.isFinite(value)) { return value } const rect = element.getBoundingClientRect() return axis === "width" ? rect.width : rect.height } function attachSizeMorph( element: HTMLElement, axis: SizeAxis, time: number, easing: string ) { if ( typeof MutationObserver === "undefined" || typeof element.animate !== "function" ) { return undefined } let settled = readSize(element, axis) let animation: Animation | null = null const morph = () => { const from = animation ? readSize(element, axis) : settled animation?.cancel() animation = null const to = readSize(element, axis) settled = to if ( Math.abs(from - to) < 0.5 || prefersReducedMotion() || !element.isConnected || element.getClientRects().length === 0 ) { element.removeAttribute("data-morphing") return } element.setAttribute("data-morphing", "") const running = element.animate( [{ [axis]: `${from}px` }, { [axis]: `${to}px` }], { duration: time, easing } ) animation = running running.onfinish = () => { if (animation === running) { animation = null element.removeAttribute("data-morphing") settled = readSize(element, axis) } } } const mutations = new MutationObserver(morph) mutations.observe(element, { childList: true, subtree: true, characterData: true, }) const resize = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(() => { if (!animation) { settled = readSize(element, axis) } }) resize?.observe(element) return () => { mutations.disconnect() resize?.disconnect() animation?.cancel() element.removeAttribute("data-morphing") } } function useSizeMorph<T extends HTMLElement>({ axis, enabled = true, duration: time = duration.morph, easing = easeOut, }: SizeMorphOptions): React.RefCallback<T> { return React.useCallback( (element: T | null) => { if (!element || !enabled) { return undefined } return attachSizeMorph(element, axis, time, easing) }, [axis, enabled, time, easing] ) } function useSlidingHighlight( barRef: React.RefObject<HTMLElement | null>, highlightRef: React.RefObject<HTMLElement | null>, selector: string, attribute = "data-popup-open" ) { React.useLayoutEffect(() => { const bar = barRef.current const highlight = highlightRef.current if (!bar || !highlight) { return } let current: HTMLElement | null = null const place = (trigger: HTMLElement, instant: boolean) => { if (instant || prefersReducedMotion()) { highlight.setAttribute("data-instant", "") } else { highlight.removeAttribute("data-instant") } const frame = bar.getBoundingClientRect() const box = trigger.getBoundingClientRect() const scale = bar.offsetWidth > 0 ? frame.width / bar.offsetWidth : 1 highlight.style.left = "0px" highlight.style.width = `${box.width / scale}px` highlight.style.height = `${box.height / scale}px` highlight.style.transform = `translate(${(box.left - frame.left) / scale - bar.clientLeft}px, ${(box.top - frame.top) / scale - bar.clientTop}px)` } const sync = () => { const trigger = bar.querySelector<HTMLElement>(selector) if (trigger === current) { if (trigger) { place(trigger, true) } return } const appearing = current === null current = trigger if (!trigger) { highlight.removeAttribute("data-visible") return } place(trigger, appearing) if (appearing) { void highlight.offsetWidth } highlight.setAttribute("data-visible", "") } sync() const mutations = new MutationObserver(sync) mutations.observe(bar, { subtree: true, childList: true, attributes: true, attributeFilter: [attribute], }) const resize = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(() => { if (current) { place(current, true) } }) resize?.observe(bar) const onScroll = () => { if (current) { place(current, true) } } bar.addEventListener("scroll", onScroll, { capture: true, passive: true }) return () => { mutations.disconnect() resize?.disconnect() bar.removeEventListener("scroll", onScroll, { capture: true }) } }, [barRef, highlightRef, selector, attribute]) } export { duration, easeInOut, easeOut, easeSpring, prefersReducedMotion, useSizeMorph, useSlidingHighlight, } export type { SizeMorphOptions } - 4
Update the import paths to match your project setup.
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
└── NativeSelectOptionGroups#
<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.
"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'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.
| Key | Action |
|---|---|
| Tab | Focuses the select. |
| SpaceEnter | Opens the OS picker. |
| ↑↓ | Changes the selection, or moves in the open picker. |
| A–Z | Jumps to the option that starts with the typed text. |
<NativeSelect /> accepts every select attribute. The option parts accept their element's attributes.
| Prop | Type | Default |
|---|---|---|
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. | boolean | true |
classNameApplied to the wrapper, so it sizes the whole control. | string | – |
value | string | – |
defaultValue | string | – |
disabled | boolean | false |
required | boolean | false |
| Attribute | Description |
|---|---|
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-invalid | Present when a surrounding Field is invalid. |
| Attribute | Description |
|---|---|
data-slot="native-select-option" | An option. |
data-slot="native-select-optgroup" | A labelled group of options. |