# Native select

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

Docs: https://hextaui.com/docs/native-select
Markdown: https://hextaui.com/docs/native-select.md

```tsx title="components/examples/native-select/demo.tsx"
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>
  )
}
```

## Installation

### CLI

```bash
npx shadcn@latest add https://hextaui.com/r/native-select.json
```

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

### Manual

Add the HextaUI theme tokens (https://hextaui.com/docs/installation#theme) to your global CSS if you haven't yet, then install the dependencies.

```bash
pnpm add @base-ui/react @tabler/icons-react class-variance-authority cn
```

Copy and paste the following code into your project.

```tsx title="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 }
```

```tsx title="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 }
```

```tsx title="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,
}
```

```ts title="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 }
```

Update the import paths to match your project setup.

## Usage

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

```tsx
<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`.

## Composition

```text
NativeSelect
├── NativeSelectOption
└── NativeSelectOptGroup
    └── NativeSelectOption
```

## Examples

### Groups

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

```tsx title="components/examples/native-select/groups.tsx"
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.

```tsx title="components/examples/native-select/sizes.tsx"
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.

```tsx title="components/examples/native-select/field.tsx"
"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`.

```tsx title="components/examples/native-select/invalid.tsx"
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.

```tsx title="components/examples/native-select/disabled.tsx"
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.

```tsx title="components/examples/native-select/controlled.tsx"
"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.

```tsx title="components/examples/native-select/long-content.tsx"
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.

```tsx title="components/examples/native-select/rtl.tsx"
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>
  )
}
```

## Native select or Select?

- 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.

## Keyboard

| Key | Action |
| --- | --- |
| `Tab` | Focuses the select. |
| `Space` `Enter` | 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. |

## API reference

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

### NativeSelect

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `size` | `"sm" \| "default" \| "lg"` | `"default"` |  |
| `htmlSize` | `number` | – | The native size attribute, renamed because size is the variant. |
| `shake` | `boolean` | `true` | Shake once when a form submit finds it invalid. |
| `className` | `string` | – | Applied to the wrapper, so it sizes the whole control. |
| `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. |

### NativeSelectOption and NativeSelectOptGroup

| Attribute | Description |
| --- | --- |
| `data-slot="native-select-option"` | An option. |
| `data-slot="native-select-optgroup"` | A labelled group of options. |

## Notes for AI assistants

- Install a component with the shadcn CLI: `npx shadcn@latest add https://hextaui.com/r/<name>.json`. It adds the source, the HextaUI theme tokens and any HextaUI components it depends on. `https://hextaui.com/r/all.json` installs every component.
- The code is then owned by the project, like shadcn/ui. There is no HextaUI npm package. HextaUI is MIT licensed and free for personal and commercial use.
- Behavior and accessibility come from Base UI (`@base-ui/react`). Compose with the `render` prop, not `asChild`.
- Styling uses Tailwind CSS v4 with theme tokens. Merge classes with `cn` from the `cn` package.
- Icons come from `@tabler/icons-react`.
- Import components from `@/components/ui/<name>`, hooks from `@/hooks/<name>` and utilities from `@/lib/<name>`.

Every HextaUI doc: https://hextaui.com/llms.txt
