# Select

> Choose one value from a known list in a compact popup.

Source: https://www.honestui.com/docs/components/select

```tsx
import {
  Select,
  SelectItem,
  SelectPopup,
  SelectTrigger,
  SelectValue,
} from "@/components/honest-ui/ui/select"

const items = [
  { label: "Select framework", value: null },
  { label: "Next.js", value: "next" },
  { label: "Vite", value: "vite" },
  { label: "Astro", value: "astro" },
]

export function SelectDemo() {
  return (
    <div className="w-full max-w-64">
      <Select items={items} defaultValue="next">
        <SelectTrigger className="w-full">
          <SelectValue />
        </SelectTrigger>
        <SelectPopup>
          {items.map(({ label, value }) => (
            <SelectItem key={value} value={value}>
              {label}
            </SelectItem>
          ))}
        </SelectPopup>
      </Select>
    </div>
  )
}

```

## Overview [#overview]

Select lets people choose exactly one value from a closed list without spending page space showing every option. The trigger shows the current choice; the popup holds the full set.

Choosing between the value controls is a real decision, so here is the whole map:

* **2–5 options where seeing all of them helps** — use Radio Group. Radio buttons show every choice at once and make the current state visible without interaction.
* **One value from a short-to-medium closed list** — Select. This component.
* **A long or searchable list** — Combobox. It filters as people type.
* **Values that do not exist on any list yet** — Tags Input or Autocomplete. A select only ever accepts what you gave it.
* **Running commands** (open, delete, navigate) — Menu, not Select. A select chooses values; it does not perform actions.
* **On/off states** — Switch. "Enabled / Disabled" as two select options forces an extra click to learn what is already knowable.

## Anatomy [#anatomy]

A select has a trigger (the closed control showing the current value), a popup listing options, and optionally grouped options with labels. The trigger's label belongs above it as static text; see [Don't do this](#dont-do-this).

The trigger sizes itself to its content, so long values widen it deliberately rather than truncating silently — give it a `className` width when it lives in a fixed layout.

## Behavior [#behavior]

**Keyboard.**

| Key                                                        | Result                                                  |
| ---------------------------------------------------------- | ------------------------------------------------------- |
| <kbd>Enter</kbd> / <kbd>Space</kbd> / <kbd>ArrowDown</kbd> | Open the popup                                          |
| <kbd>ArrowUp</kbd> / <kbd>ArrowDown</kbd>                  | Move between options                                    |
| Character keys                                             | Typeahead — jump to options starting with those letters |
| <kbd>Enter</kbd>                                           | Choose the highlighted option and close                 |
| <kbd>Escape</kbd>                                          | Close without changing the value                        |
| <kbd>Tab</kbd>                                             | Close the popup and leave the control                   |

**Opening never commits.** Browsing options with the keyboard changes the highlight, not the value. The value changes only on explicit selection — someone can open the popup to look, press <kbd>Escape</kbd>, and their original setting is intact.

**Grouping.** Use `SelectGroup` with `SelectGroupLabel` past about ten options. Group labels are announced by screen readers, giving structure that a flat list of twenty items lacks.

**Multiple values.** Select handles a multiple mode for compact tag-style choices; for anything user-facing where each selection deserves visibility, prefer MultiSelect from the Product family.

## States [#states]

**Disabled** selects are skipped by keyboard focus and excluded from submission. **Read-only data that must submit** has no place in a select at all — render text instead; a control that cannot be changed should not look changeable.

**Invalid.** Setting `aria-invalid="true"` switches the trigger border to the danger color and announces the state to assistive technology. Pair it with an error message tied via `aria-describedby` that says what to choose and why.

**Empty.** An unchosen required select shows its placeholder ("Choose a region…"). That placeholder disappears the moment a value exists — which is correct for a trigger, and exactly why it cannot replace a label.

## Accessibility [#accessibility]

The trigger carries its accessible name from your visible label (`Label` + `htmlFor`, or a Field wrapper). Options are announced with their selected/checked state and group labels. The popup implements the standard listbox keyboard model shown above; focus returns to the trigger when it closes.

Typeahead matches from the start of option labels, so option copy matters for keyboard speed: "United States" is findable under U, "US-based customers" is not.

Colors come from tokens and adapt to both themes automatically — trigger, popup, and highlighted-option states included. The popup positions with collision handling and mirrors correctly in RTL, including typeahead matching against the option text as written. Option rows are sized for touch through the same spacing scale as other controls; very long option labels wrap inside the popup rather than overflowing.

## Installation [#installation]


  

  
    <CliBlock commands="[&#x22;select&#x22;]" />
  

  
    
      
        Install the following dependencies:
      

      ```bash
      npm install @base-ui-components/react
      ```

      
        Copy and paste the following code into your project.
      

      ### components/ui/select.tsx

```tsx
"use client"

import * as React from "react"
import {
  Combobox as ComboboxPrimitive,
  Select as SelectPrimitive,
} from "@base-ui/react"
import { cva, type VariantProps } from "class-variance-authority"
import { ChevronDown as ChevronDownIcon } from "honestui/icons"

import { Checkbox } from "@/components/ui/checkbox"
import { cn } from "@/lib/utils"

type ItemType = {
  leadingIcon?: React.ReactNode
  children: React.ReactNode
  value: string
}

type ValueType = Omit<ItemType, "children"> & {
  children: React.ReactNode
}

type CommonProps = {
  autocomplete?: boolean
  autocompleteMode?: "auto" | "manual"
  searchValue?: string
  onSearch?: (value: string) => void
  defaultSearchValue?: string
}

type PrimitiveRootProps = Omit<
  SelectPrimitive.Root.Props<string, false>,
  | "children"
  | "defaultValue"
  | "items"
  | "multiple"
  | "onValueChange"
  | "value"
>

type BaseSelectProps = PrimitiveRootProps &
  CommonProps & {
    children?: React.ReactNode
    items?: unknown
  }

type SingleSelectProps = BaseSelectProps & {
  multiple?: false
  value?: string | null
  onValueChange?: (value: string | null) => void
  defaultValue?: string | null
}

type MultipleSelectProps = BaseSelectProps & {
  multiple: true
  value?: string[]
  onValueChange?: (value: string[]) => void
  defaultValue?: string[]
}

type SelectRootProps = SingleSelectProps | MultipleSelectProps

type SelectContextValue = CommonProps & {
  value?: string | string[] | null
  registerItem: (item: ItemType) => void
  unregisterItem: (value: string) => void
  multiple: boolean
  items: Record<string, ItemType>
  hasItems: boolean
}

type UseSelectContext = SelectContextValue & {
  shouldFilter: boolean
}

const SelectContext = React.createContext<SelectContextValue | undefined>(
  undefined
)

function useSelectContext(): UseSelectContext {
  const context = React.useContext(SelectContext)

  if (!context) {
    throw new Error("Select components must be used within Select")
  }

  return {
    ...context,
    shouldFilter: Boolean(
      context.autocomplete &&
        context.autocompleteMode === "auto" &&
        context.searchValue?.length
    ),
  }
}

function normalizeItems(items: unknown): Record<string, ItemType> {
  const normalized: Record<string, ItemType> = {}

  function addItems(value: unknown) {
    if (Array.isArray(value)) {
      value.forEach(addItems)
      return
    }

    if (typeof value === "string") {
      normalized[value] = { children: value, value }
      return
    }

    if (!value || typeof value !== "object" || React.isValidElement(value)) {
      return
    }

    const record = value as Record<string, unknown>

    if ("items" in record && Array.isArray(record.items)) {
      addItems(record.items)
      return
    }

    if ("value" in record) {
      const itemValue = record.value == null ? "" : String(record.value)
      normalized[itemValue] = {
        children:
          (record.label as React.ReactNode) ??
          (record.children as React.ReactNode) ??
          itemValue,
        leadingIcon: record.leadingIcon as React.ReactNode,
        value: itemValue,
      }
      return
    }

    Object.entries(record).forEach(([itemValue, label]) => {
      normalized[itemValue] = {
        children: label as React.ReactNode,
        value: itemValue,
      }
    })
  }

  addItems(items)
  return normalized
}

function SelectRoot(props: SelectRootProps) {
  const {
    children,
    value: providedValue,
    onValueChange,
    defaultValue,
    autocomplete,
    autocompleteMode = "auto",
    searchValue: providedSearchValue,
    onSearch,
    defaultSearchValue = "",
    open: providedOpen,
    defaultOpen = false,
    onOpenChange,
    multiple = false,
    items: itemsProp,
    ...rootProps
  } = props

  const [internalValue, setInternalValue] = React.useState<
    string | string[] | null | undefined
  >(defaultValue)
  const [internalSearchValue, setInternalSearchValue] =
    React.useState(defaultSearchValue)
  const [registeredItems, setRegisteredItems] = React.useState<
    Record<string, ItemType>
  >({})

  const computedValue =
    providedValue === undefined ? internalValue : providedValue
  const searchValue =
    providedSearchValue === undefined
      ? internalSearchValue
      : providedSearchValue

  const handleValueChange = React.useCallback(
    (nextValue: string | string[] | null) => {
      if (providedValue === undefined) {
        setInternalValue(nextValue)
      }

      if (multiple) {
        ;(onValueChange as MultipleSelectProps["onValueChange"])?.(
          nextValue as string[]
        )
      } else {
        ;(onValueChange as SingleSelectProps["onValueChange"])?.(
          nextValue as string | null
        )
      }
    },
    [multiple, onValueChange, providedValue]
  )

  const handleSearchValueChange = React.useCallback(
    (nextValue: string) => {
      if (providedSearchValue === undefined) {
        setInternalSearchValue(nextValue)
      }
      onSearch?.(nextValue)
    },
    [onSearch, providedSearchValue]
  )

  const registerItem = React.useCallback((item: ItemType) => {
    setRegisteredItems((currentItems) => ({
      ...currentItems,
      [item.value]: item,
    }))
  }, [])

  const unregisterItem = React.useCallback((value: string) => {
    setRegisteredItems((currentItems) => {
      const nextItems = { ...currentItems }
      delete nextItems[value]
      return nextItems
    })
  }, [])

  const normalizedItems = React.useMemo(
    () => normalizeItems(itemsProp),
    [itemsProp]
  )
  const items = React.useMemo(
    () => ({ ...normalizedItems, ...registeredItems }),
    [normalizedItems, registeredItems]
  )

  const contextValue = React.useMemo<SelectContextValue>(
    () => ({
      value: computedValue,
      registerItem,
      unregisterItem,
      autocomplete,
      autocompleteMode,
      searchValue,
      onSearch,
      defaultSearchValue,
      multiple,
      items,
      hasItems: itemsProp !== undefined,
    }),
    [
      autocomplete,
      autocompleteMode,
      computedValue,
      defaultSearchValue,
      items,
      itemsProp,
      multiple,
      onSearch,
      registerItem,
      searchValue,
      unregisterItem,
    ]
  )

  if (autocomplete) {
    const comboboxProps = {
      ...rootProps,
      value: providedValue,
      defaultValue,
      onValueChange: handleValueChange,
      inputValue: providedSearchValue,
      defaultInputValue: defaultSearchValue,
      onInputValueChange: handleSearchValueChange,
      open: providedOpen,
      defaultOpen,
      onOpenChange,
      multiple,
      modal: true,
      filter: itemsProp === undefined ? null : undefined,
      items: itemsProp,
      loopFocus: false,
      autoHighlight: true,
    } as ComboboxPrimitive.Root.Props<string, boolean>

    return (
      <SelectContext.Provider value={contextValue}>
        <ComboboxPrimitive.Root<string, boolean> {...comboboxProps}>
          {children}
        </ComboboxPrimitive.Root>
      </SelectContext.Provider>
    )
  }

  const selectProps = {
    ...rootProps,
    value: providedValue,
    defaultValue,
    onValueChange: handleValueChange,
    open: providedOpen,
    defaultOpen,
    onOpenChange,
    multiple,
    modal: true,
  } as SelectPrimitive.Root.Props<string, boolean>

  return (
    <SelectContext.Provider value={contextValue}>
      <SelectPrimitive.Root<string, boolean> {...selectProps}>
        {children}
      </SelectPrimitive.Root>
    </SelectContext.Provider>
  )
}

SelectRoot.displayName = "Select"

const selectTriggerVariants = cva(
  "flex items-center justify-between rounded-[var(--hui-radius-2)] bg-[var(--hui-color-background-base-primary)] text-[var(--hui-color-foreground-base-primary)] outline-none select-none [font-size:var(--hui-font-size-small)] [letter-spacing:var(--hui-letter-spacing-small)] [line-height:var(--hui-line-height-small)] motion-safe:[transition:var(--hui-transition-interactive)] not-data-disabled:hover:cursor-pointer not-data-disabled:hover:bg-[var(--hui-color-background-base-primary-hover)] not-data-disabled:active:bg-[var(--hui-color-background-neutral-secondary)] focus:not-focus-visible:outline-none focus-visible:[outline:var(--hui-focus-ring)] data-disabled:pointer-events-none data-disabled:opacity-50 disabled:pointer-events-none disabled:opacity-50 data-[multiselectable=true]:py-[var(--hui-space-2)]",
  {
    variants: {
      size: {
        small:
          "min-h-[var(--hui-space-7)] overflow-hidden p-[var(--hui-space-2)]",
        medium:
          "min-h-[var(--hui-space-9)] overflow-hidden p-[var(--hui-space-3)]",
      },
      variant: {
        outline:
          "border-[0.5px] border-[var(--hui-color-border-base-tertiary)]",
        text: "border-0",
      },
    },
    defaultVariants: {
      size: "medium",
      variant: "outline",
    },
  }
)

type SelectTriggerProps = Omit<
  React.ComponentProps<"button">,
  "size"
> &
  Omit<VariantProps<typeof selectTriggerVariants>, "size"> & {
    size?: "sm" | "default" | "lg" | "small" | "medium"
    nativeButton?: boolean
  }

function SelectTrigger({
  ref,
  size = "medium",
  variant,
  className,
  children,
  "aria-label": ariaLabel,
  ...props
}: SelectTriggerProps) {
  const { multiple, autocomplete } = useSelectContext()
  const resolvedSize = size === "sm" || size === "small" ? "small" : "medium"
  const TriggerPrimitive = autocomplete
    ? ComboboxPrimitive.Trigger
    : SelectPrimitive.Trigger

  return (
    <TriggerPrimitive
      ref={ref}
      data-multiselectable={multiple ? true : undefined}
      data-size={resolvedSize}
      data-slot="select-trigger"
      data-variant={variant ?? "outline"}
      className={cn(
        selectTriggerVariants({ size: resolvedSize, variant }),
        className
      )}
      aria-label={ariaLabel || "Select option"}
      {...props}
    >
      <span
        className={cn(
          "flex flex-1 items-center overflow-hidden text-ellipsis whitespace-nowrap [font-style:normal] [font-weight:var(--hui-font-weight-medium)]",
          resolvedSize === "small"
            ? "gap-[var(--hui-space-1)] [font-size:var(--hui-font-size-mini)] [letter-spacing:var(--hui-letter-spacing-mini)] [line-height:var(--hui-line-height-mini)]"
            : "gap-[var(--hui-space-2)] [font-size:var(--hui-font-size-small)] [letter-spacing:var(--hui-letter-spacing-small)] [line-height:var(--hui-line-height-small)]"
        )}
        data-slot="select-trigger-content"
      >
        {children}
      </span>
      <ChevronDownIcon
        aria-hidden="true"
        className={cn(
          "shrink-0 text-[var(--hui-color-foreground-base-secondary)]",
          resolvedSize === "small"
            ? "ms-[var(--hui-space-2)] size-[var(--hui-space-4)]"
            : "ms-[var(--hui-space-3)] size-[var(--hui-space-5)]"
        )}
        data-slot="select-trigger-icon"
      />
    </TriggerPrimitive>
  )
}

SelectTrigger.displayName = "Select.Trigger"

type SelectValueProps = Omit<React.ComponentProps<"span">, "children"> & {
  placeholder?: string
  children?:
    | ((value?: ValueType | ValueType[]) => React.ReactNode)
    | React.ReactNode
}

function SelectValue({
  children,
  placeholder,
  className,
  ...props
}: SelectValueProps) {
  const { value, items, multiple } = useSelectContext()
  const placeholderContent = placeholder ?? items[""]?.children
  const hasValue = multiple
    ? Array.isArray(value) && value.length > 0
    : value !== undefined && value !== null && value !== ""

  const item = React.useMemo(() => {
    if (!hasValue) return undefined

    if (multiple && Array.isArray(value)) {
      return value.map(
        (itemValue) =>
          items[itemValue] ?? {
            children: itemValue,
            value: itemValue,
          }
      )
    }

    const itemValue = String(value)
    return (
      items[itemValue] ?? {
        children: itemValue,
        value: itemValue,
      }
    )
  }, [hasValue, items, multiple, value])

  if (!hasValue) {
    return (
      <span
        data-placeholder=""
        data-slot="select-value"
        className={cn(
          "text-[var(--hui-color-foreground-base-tertiary)]",
          className
        )}
        {...props}
      >
        {placeholderContent}
      </span>
    )
  }

  if (typeof children === "function") {
    return (
      <span data-slot="select-value" className={className} {...props}>
        {children(item)}
      </span>
    )
  }

  if (children) {
    return (
      <span data-slot="select-value" className={className} {...props}>
        {children}
      </span>
    )
  }

  if (Array.isArray(item)) {
    return <SelectMultipleValue data={item} />
  }

  return (
    <span data-slot="select-value" className={className} {...props}>
      <span
        className="flex h-full max-w-full flex-1 items-center gap-[var(--hui-space-3)] overflow-hidden text-ellipsis whitespace-nowrap in-data-[size=small]:gap-[var(--hui-space-2)]"
        data-slot="select-value-content"
      >
        {typeof item?.children === "string" && item.leadingIcon && (
          <span
            className="flex shrink-0 items-center justify-center [&_svg]:size-[var(--hui-space-5)] in-data-[size=small]:[&_svg]:size-[var(--hui-space-4)]"
            data-slot="select-value-icon"
          >
            {item.leadingIcon}
          </span>
        )}
        {item?.children ?? String(value)}
      </span>
    </span>
  )
}

SelectValue.displayName = "Select.Value"

function calculateTextWidth(text: string, fontSize = 11) {
  return text.length * fontSize * 0.6
}

function SelectMultipleValue({ data = [] }: { data: ItemType[] }) {
  const containerRef = React.useRef<HTMLSpanElement>(null)
  const [visibleCount, setVisibleCount] = React.useState(data.length)
  const [containerWidth, setContainerWidth] = React.useState(0)

  React.useLayoutEffect(() => {
    const container = containerRef.current
    if (!container || typeof ResizeObserver === "undefined") return

    const resizeObserver = new ResizeObserver((entries) => {
      entries.forEach((entry) => {
        setContainerWidth(Math.max(0, entry.contentRect.width - 70))
      })
    })

    resizeObserver.observe(container)
    return () => resizeObserver.disconnect()
  }, [])

  React.useLayoutEffect(() => {
    if (!containerRef.current || data.length === 0 || containerWidth === 0) {
      return
    }

    const chipWidths = data.map((item) => {
      const text =
        typeof item.children === "string" ? item.children : item.value
      const iconWidth = item.leadingIcon ? 16 : 0
      return calculateTextWidth(text) + 8 + iconWidth
    })

    let totalWidth = chipWidths[0] ?? 0
    let count = data.length > 0 ? 1 : 0

    for (let index = 1; index < data.length; index += 1) {
      const newWidth = totalWidth + (chipWidths[index] ?? 0)
      if (newWidth > containerWidth) break
      count += 1
      totalWidth = newWidth
    }

    setVisibleCount(count)
  }, [containerWidth, data])

  return (
    <span
      ref={containerRef}
      className="flex h-full max-w-full flex-1 items-center gap-[var(--hui-space-2)] overflow-hidden whitespace-nowrap"
      data-slot="select-value"
    >
      {data.slice(0, visibleCount).map((item) => (
        <span
          key={item.value}
          className="inline-flex min-w-0 shrink-0 items-center gap-[var(--hui-space-1)] rounded-[var(--hui-radius-1)] bg-[var(--hui-color-background-neutral-secondary)] px-[var(--hui-space-2)] py-[var(--hui-space-1)] [font-size:var(--hui-font-size-mini)] [line-height:var(--hui-line-height-mini)]"
          data-slot="select-value-chip"
        >
          {item.leadingIcon && (
            <span className="flex size-[var(--hui-space-4)] items-center justify-center [&_svg]:size-full">
              {item.leadingIcon}
            </span>
          )}
          {typeof item.children === "string" ? item.children : item.value}
        </span>
      ))}
      {data.length > visibleCount && (
        <span
          className="shrink-0 text-[var(--hui-color-foreground-base-primary)] [font-size:var(--hui-font-size-small)]"
          data-slot="select-value-overflow"
        >
          +{data.length - visibleCount}
        </span>
      )}
    </span>
  )
}

type SelectContentProps = SelectPrimitive.Popup.Props & {
  searchPlaceholder?: string
  sideOffset?: SelectPrimitive.Positioner.Props["sideOffset"]
  side?: SelectPrimitive.Positioner.Props["side"]
  align?: SelectPrimitive.Positioner.Props["align"]
  alignItemWithTrigger?: SelectPrimitive.Positioner.Props["alignItemWithTrigger"]
}

function SelectContent({
  className,
  children,
  searchPlaceholder = "Search...",
  sideOffset = 4,
  side = "bottom",
  align = "start",
  alignItemWithTrigger = false,
  ...props
}: SelectContentProps) {
  const { autocomplete, multiple } = useSelectContext()
  const contentClassName = cn(
    "relative box-border max-h-[320px] min-w-(--anchor-width) origin-(--transform-origin) overflow-auto rounded-[var(--hui-radius-2)] border-[0.5px] border-[var(--hui-color-border-base-primary)] bg-[var(--hui-color-background-base-primary)] shadow-[var(--hui-shadow-soft)] [--apsara-select-padding:var(--hui-space-2)] [font-size:var(--hui-font-size-small)] [letter-spacing:var(--hui-letter-spacing-small)] [line-height:var(--hui-line-height-small)] [transition:opacity_var(--hui-duration-fast)_var(--hui-ease-out)] data-ending-style:opacity-0 data-starting-style:opacity-0 motion-safe:[transition:opacity_var(--hui-duration-fast)_var(--hui-ease-out),transform_var(--hui-duration-fast)_var(--hui-ease-out)] motion-safe:data-ending-style:scale-[0.97] motion-safe:data-starting-style:scale-[0.97] has-[[data-slot=select-list]:empty]:[&_[data-slot=select-search]]:border-b-0 has-[[data-slot=select-list]:not(:has([data-slot=select-item]:not([data-hidden=true])))]:[&_[data-slot=select-search]]:border-b-0",
    className
  )

  if (autocomplete) {
    return (
      <ComboboxPrimitive.Portal keepMounted>
        <ComboboxPrimitive.Positioner
          sideOffset={sideOffset}
          side={side}
          align={align}
          className="z-[var(--hui-z-index-portal)]"
          data-slot="select-positioner"
        >
          <ComboboxPrimitive.Popup
            className={contentClassName}
            data-multiselectable={multiple ? true : undefined}
            data-slot="select-content"
            {...props}
          >
            <ComboboxPrimitive.Input
              placeholder={searchPlaceholder}
              className="sticky top-0 z-2 w-full rounded-t-[var(--hui-radius-2)] border-0 border-b-[0.5px] border-b-[var(--hui-color-border-base-primary)] bg-[var(--hui-color-background-base-primary)] px-[var(--hui-space-4)] py-[var(--hui-space-3)] text-[var(--hui-color-foreground-base-primary)] outline-none [font-size:var(--hui-font-size-small)] [font-weight:var(--hui-font-weight-regular)] [letter-spacing:var(--hui-letter-spacing-small)] [line-height:var(--hui-line-height-small)]"
              size={12}
              data-slot="select-search"
            />
            <ComboboxPrimitive.List
              className="p-[var(--apsara-select-padding)] empty:p-0 [&:not(:has([data-slot=select-item]:not([data-hidden=true])))]:p-0"
              data-slot="select-list"
            >
              {children}
            </ComboboxPrimitive.List>
          </ComboboxPrimitive.Popup>
        </ComboboxPrimitive.Positioner>
      </ComboboxPrimitive.Portal>
    )
  }

  return (
    <SelectPrimitive.Portal>
      <SelectPrimitive.Positioner
        sideOffset={sideOffset}
        side={side}
        align={align}
        alignItemWithTrigger={alignItemWithTrigger}
        className="z-[var(--hui-z-index-portal)]"
        data-slot="select-positioner"
      >
        <SelectPrimitive.Popup
          className={contentClassName}
          data-multiselectable={multiple ? true : undefined}
          data-slot="select-content"
          {...props}
        >
          <SelectPrimitive.List
            className="p-[var(--apsara-select-padding)]"
            data-slot="select-list"
          >
            {children}
          </SelectPrimitive.List>
        </SelectPrimitive.Popup>
      </SelectPrimitive.Positioner>
    </SelectPrimitive.Portal>
  )
}

SelectContent.displayName = "Select.Content"

function getTextContent(node: React.ReactNode): string {
  return React.Children.toArray(node)
    .map((child) => {
      if (typeof child === "string" || typeof child === "number") {
        return String(child)
      }
      if (React.isValidElement<{ children?: React.ReactNode }>(child)) {
        return getTextContent(child.props.children)
      }
      return ""
    })
    .join(" ")
}

type SelectItemProps = SelectPrimitive.Item.Props & {
  leadingIcon?: React.ReactNode
}

function SelectItem({
  className,
  children,
  value: providedValue,
  leadingIcon,
  disabled,
  ...props
}: SelectItemProps) {
  const value = providedValue == null ? "" : String(providedValue)
  const primitiveValue = providedValue == null ? null : value
  const {
    registerItem,
    unregisterItem,
    autocomplete,
    searchValue,
    value: selectValue,
    shouldFilter,
    hasItems,
    multiple,
  } = useSelectContext()

  const isSelected = multiple
    ? Array.isArray(selectValue) && selectValue.includes(value)
    : value === selectValue
  const searchableText = `${value} ${getTextContent(children)}`.toLowerCase()
  const isMatched = searchableText.includes((searchValue ?? "").toLowerCase())
  const isHidden = shouldFilter && !hasItems && isSelected && !isMatched

  React.useLayoutEffect(() => {
    registerItem({ leadingIcon, children, value })
    return () => unregisterItem(value)
  }, [children, leadingIcon, registerItem, unregisterItem, value])

  if (shouldFilter && !hasItems && !isMatched && !isSelected) {
    return null
  }

  const element =
    typeof children === "string" ? (
      <>
        {leadingIcon && (
          <span
            className="flex shrink-0 items-center justify-center [&_svg]:size-[var(--hui-space-5)]"
            data-slot="select-item-icon"
          >
            {leadingIcon}
          </span>
        )}
        <span data-slot="select-item-text">{children}</span>
      </>
    ) : (
      children
    )

  const itemClassName = cn(
    "relative flex items-center gap-[var(--hui-space-3)] rounded-[var(--hui-radius-2)] p-[var(--hui-space-3)] text-[var(--hui-color-foreground-base-primary)] whitespace-normal outline-none [word-break:break-word] data-disabled:pointer-events-none data-disabled:opacity-50 data-highlighted:cursor-pointer data-highlighted:bg-[var(--hui-color-background-base-primary-hover)] data-[hidden=true]:hidden",
    className
  )
  const renderItem = (
    renderProps: React.HTMLAttributes<HTMLDivElement>,
    state: { selected: boolean }
  ) => (
    <div {...renderProps}>
      {multiple && (
        <Checkbox
          checked={state.selected}
          readOnly
          tabIndex={-1}
          aria-hidden="true"
          className="pointer-events-none"
        />
      )}
      {element}
    </div>
  )

  if (autocomplete) {
    return (
      <ComboboxPrimitive.Item
        value={primitiveValue}
        className={itemClassName}
        data-hidden={isHidden || undefined}
        data-slot="select-item"
        disabled={disabled || isHidden}
        {...props}
        render={renderItem}
      />
    )
  }

  return (
    <SelectPrimitive.Item
      value={primitiveValue}
      className={itemClassName}
      data-hidden={isHidden || undefined}
      data-slot="select-item"
      disabled={disabled || isHidden}
      {...props}
      render={renderItem}
    />
  )
}

SelectItem.displayName = "Select.Item"

type SelectGroupProps = SelectPrimitive.Group.Props

function SelectGroup({ className, children, ...props }: SelectGroupProps) {
  const { shouldFilter, autocomplete } = useSelectContext()

  if (shouldFilter) return <>{children}</>

  const GroupPrimitive = autocomplete
    ? ComboboxPrimitive.Group
    : SelectPrimitive.Group

  return (
    <GroupPrimitive
      className={className}
      data-slot="select-group"
      {...props}
    >
      {children}
    </GroupPrimitive>
  )
}

SelectGroup.displayName = "Select.Group"

type SelectLabelProps = SelectPrimitive.GroupLabel.Props

function SelectLabel({ className, ...props }: SelectLabelProps) {
  const { shouldFilter, autocomplete } = useSelectContext()

  if (shouldFilter) return null

  const LabelPrimitive = autocomplete
    ? ComboboxPrimitive.GroupLabel
    : SelectPrimitive.GroupLabel

  return (
    <LabelPrimitive
      className={cn(
        "px-[var(--hui-space-3)] py-[var(--hui-space-2)] [font-size:var(--hui-font-size-mini)] [font-weight:var(--hui-font-weight-medium)]",
        className
      )}
      data-slot="select-label"
      {...props}
    />
  )
}

SelectLabel.displayName = "Select.Label"

type SelectSeparatorProps = SelectPrimitive.Separator.Props

function SelectSeparator({ className, ...props }: SelectSeparatorProps) {
  const { shouldFilter, autocomplete } = useSelectContext()

  if (shouldFilter) return null

  const SeparatorPrimitive = autocomplete
    ? ComboboxPrimitive.Separator
    : SelectPrimitive.Separator

  return (
    <SeparatorPrimitive
      className={cn(
        "my-[var(--hui-space-2)] h-px bg-[var(--hui-color-border-base-primary)] [margin-inline:calc(var(--hui-space-3)*-1)]",
        className
      )}
      data-slot="select-separator"
      {...props}
    />
  )
}

SelectSeparator.displayName = "Select.Separator"

const Select = Object.assign(SelectRoot, {
  Group: SelectGroup,
  Value: SelectValue,
  ScrollUpArrow: SelectPrimitive.ScrollUpArrow,
  ScrollDownArrow: SelectPrimitive.ScrollDownArrow,
  List: SelectPrimitive.List,
  Trigger: SelectTrigger,
  Content: SelectContent,
  Item: SelectItem,
  Separator: SelectSeparator,
  Label: SelectLabel,
})

const SelectPopup = SelectContent
const SelectGroupLabel = SelectLabel

export {
  Select,
  SelectRoot,
  SelectTrigger,
  SelectValue,
  SelectContent,
  SelectPopup,
  SelectItem,
  SelectGroup,
  SelectLabel,
  SelectGroupLabel,
  SelectSeparator,
  type ItemType,
  type SelectContentProps,
  type SelectItemProps,
  type SelectRootProps,
  type SelectTriggerProps,
  type SelectValueProps,
}

```

      
        Update the import paths to match your project setup.
      
    
  


## Usage [#usage]

```tsx
import { Label } from "@/components/ui/label";
import {
  Select,
  SelectItem,
  SelectPopup,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select";
```

```tsx
<Select items={regions} defaultValue={null}>
  <SelectTrigger className="w-full">
    <SelectValue />
  </SelectTrigger>
  <SelectPopup>
    {regions.map((region) => (
      <SelectItem key={region.value} value={region.value}>
        {region.label}
      </SelectItem>
    ))}
  </SelectPopup>
</Select>
```

## Don't do this [#dont-do-this]

### A binary choice behind a popup [#a-binary-choice-behind-a-popup]

```tsx
// Bad
<Select items={[{ label: "Enabled", value: "on" }, { label: "Disabled", value: "off" }]} />
```

```tsx
// Good
<Switch defaultChecked /> Notifications
```

Two options do not need opening anything. A switch shows its state from across the room; a collapsed select hides the answer until clicked, costs two interactions to change, and reads like there might be more options hiding in there.

### The placeholder doing the label's job [#the-placeholder-doing-the-labels-job]

```tsx
// Bad
<Select items={sizes} defaultValue={null} />
// trigger shows "Choose size…" — until it doesn't
```

```tsx
// Good
<Label htmlFor={id}>Size</Label>
<Select id-linked items={sizes} defaultValue={null} />
```

Once someone chooses, the trigger shows the value and "Choose size…" is gone forever. Anyone returning to the form — or filling in the third similar field in a row — can no longer tell what the control asks for. Screen readers also may not announce a placeholder as the field's name. Keep a permanent label.

### Actions dressed up as values [#actions-dressed-up-as-values]

```tsx
// Bad
<Select items={[
  { label: "Duplicate project", value: "duplicate" },
  { label: "Delete project…", value: "delete" },
]} onChange={(value) => runAction(value)} />
```

```tsx
// Good
<Menu>
  <MenuTrigger render={<Button variant="outline" />}>Project actions</MenuTrigger>
  <MenuPopup>{/* Duplicate, Delete… */}</MenuPopup>
</Menu>
```

A select answers "which value does this field hold?" — after choosing Delete, the field would claim to *hold* deletion, which is nonsense. Menus run commands and close; selects commit state. People also expect a select's popup to reopen showing their "choice", and an action breaks that model mid-flow.

## Examples [#examples]

### Groups, alignment, and sizes [#groups-alignment-and-sizes]

```tsx
import {
  Select,
  SelectGroup,
  SelectGroupLabel,
  SelectItem,
  SelectPopup,
  SelectSeparator,
  SelectTrigger,
  SelectValue,
} from "@/components/honest-ui/ui/select"

const placeholder = [{ label: "Select framework", value: null }]

const frontend = [
  { label: "Next.js", value: "next" },
  { label: "Vite", value: "vite" },
  { label: "Astro", value: "astro" },
]

const backend = [
  { label: "Express", value: "express" },
  { label: "NestJS", value: "nestjs" },
  { label: "Fastify", value: "fastify" },
  { label: "Django", value: "django" },
  { label: "Flask", value: "flask" },
  { label: "Rails", value: "rails" },
]

export function SelectWithGroups() {
  return (
    <div className="w-full max-w-64">
      <Select items={[...placeholder, ...frontend, ...backend]}>
        <SelectTrigger className="w-full">
          <SelectValue />
        </SelectTrigger>
        <SelectPopup>
          <SelectGroup>
            <SelectGroupLabel>Frontend</SelectGroupLabel>
            {frontend.map(({ label, value }) => (
              <SelectItem key={value} value={value}>
                {label}
              </SelectItem>
            ))}
          </SelectGroup>
          <SelectSeparator />
          <SelectGroup>
            <SelectGroupLabel>Backend</SelectGroupLabel>
            {backend.map(({ label, value }) => (
              <SelectItem key={value} value={value}>
                {label}
              </SelectItem>
            ))}
          </SelectGroup>
        </SelectPopup>
      </Select>
    </div>
  )
}

```

### Disabled [#disabled]

```tsx
import {
  Select,
  SelectItem,
  SelectPopup,
  SelectTrigger,
  SelectValue,
} from "@/components/honest-ui/ui/select"

const items = [
  { label: "Select framework", value: null },
  { label: "Next.js", value: "next" },
  { label: "Vite", value: "vite" },
  { label: "Astro", value: "astro" },
]

export function SelectDisabled() {
  return (
    <div className="w-full max-w-64">
      <Select items={items}>
        <SelectTrigger className="w-full" disabled>
          <SelectValue />
        </SelectTrigger>
        <SelectPopup>
          {items.map(({ label, value }) => (
            <SelectItem key={value} value={value}>
              {label}
            </SelectItem>
          ))}
        </SelectPopup>
      </Select>
    </div>
  )
}

```

### In a form [#in-a-form]

Validation wiring comes from Field — the select reports invalid state automatically.

```tsx
"use client";

import * as React from "react";

import { Button } from "@/components/honest-ui/ui/button";
import {
  Field,
  FieldDescription,
  FieldError,
  FieldHelperSlot,
  FieldLabel,
} from "@/components/honest-ui/ui/field";
import { Form } from "@/components/honest-ui/ui/form";
import {
  Select,
  SelectItem,
  SelectPopup,
  SelectTrigger,
  SelectValue,
} from "@/components/honest-ui/ui/select";

const items = [
  { label: "Select a framework", value: null },
  { label: "Next.js", value: "next" },
  { label: "Vite", value: "vite" },
  { label: "Astro", value: "astro" },
];

export function SelectForm() {
  const [status, setStatus] = React.useState("");
  const onSubmit = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    const formData = new FormData(e.currentTarget);
    setStatus(`Submitted framework: ${formData.get("framework")}.`);
  };

  return (
    <Form onSubmit={onSubmit} className="grid w-full max-w-64 gap-4">
      <Field>
        <FieldLabel>Framework</FieldLabel>
        <Select name="framework" items={items} required>
          <SelectTrigger className="w-full">
            <SelectValue />
          </SelectTrigger>
          <SelectPopup>
            {items.map(({ label, value }) => (
              <SelectItem key={value} value={value}>
                {label}
              </SelectItem>
            ))}
          </SelectPopup>
        </Select>
        <FieldHelperSlot>
          <FieldDescription>
            Choose the framework used by this project.
          </FieldDescription>
          <FieldError>Select a framework.</FieldError>
        </FieldHelperSlot>
      </Field>

      <Button type="submit">Save framework</Button>
      <p className="text-sm text-muted-foreground" role="status">
        {status}
      </p>
    </Form>
  );
}

```

### Multiple values [#multiple-values]

```tsx
"use client"

import {
  Select,
  SelectItem,
  SelectPopup,
  SelectTrigger,
  SelectValue,
} from "@/components/honest-ui/ui/select"

const languages = {
  javascript: "JavaScript",
  typescript: "TypeScript",
  python: "Python",
  java: "Java",
  csharp: "C#",
  php: "PHP",
  cpp: "C++",
  rust: "Rust",
  go: "Go",
  swift: "Swift",
}

type Language = keyof typeof languages

const values = Object.keys(languages) as Language[]

function renderValue(
  value?: { value: string } | { value: string }[]
) {
  const selectedItems = Array.isArray(value) ? value : value ? [value] : []

  if (selectedItems.length === 0) {
    return "Select languages…"
  }

  const firstValue = selectedItems[0]?.value as Language | undefined
  const firstLanguage = firstValue ? languages[firstValue] : ""
  const additionalLanguages =
    selectedItems.length > 1 ? ` (+${selectedItems.length - 1} more)` : ""
  return firstLanguage + additionalLanguages
}

export function SelectMultiple() {
  return (
    <div className="w-full max-w-64">
      <Select multiple defaultValue={["javascript", "typescript"]}>
        <SelectTrigger className="w-full">
          <SelectValue>{renderValue}</SelectValue>
        </SelectTrigger>
        <SelectPopup alignItemWithTrigger={false}>
          {values.map((value) => (
            <SelectItem key={value} value={value}>
              {languages[value]}
            </SelectItem>
          ))}
        </SelectPopup>
      </Select>
    </div>
  )
}

```

## API reference [#api-reference]

`Select` accepts Base UI Select props plus Honest UI additions:

| Prop       | Values                                | Default |
| ---------- | ------------------------------------- | ------- |
| `items`    | `{ label: string; value: unknown }[]` | —       |
| `multiple` | boolean                               | `false` |

Parts: `SelectTrigger`, `SelectValue`, `SelectPopup`, `SelectItem`, `SelectGroup`, `SelectGroupLabel`. Native attributes such as `disabled`, `required`, and `name` pass through; validation state follows `aria-invalid`.

See the [Base UI Select API](https://base-ui.com/react/components/select#api-reference).
