uiatom
Select
A drop-down list built on Base UI's Select and Field. The label, help text and error message are connected to the control, and a hidden input carries the chosen value when a form is submitted.
Preview
Installation
pnpm dlx shadcn@latest add @facade/selectThis component needs @base-ui-components/react@1.0.0-rc.0, lucide-react@^1.47.0, utils. The CLI installs them for you.
Usage
This is the code for the preview above. To install it as a file, run npx shadcn@latest add @facade/select-demo.
import { Select } from "@registry/ui/select"
const COUNTRIES = [
{ value: "nl", label: "Netherlands" },
{ value: "de", label: "Germany" },
{ value: "fr", label: "France" },
{ value: "be", label: "Belgium" },
{ value: "xx", label: "Atlantis", disabled: true },
]
const PLANS = [
{ value: "starter", label: "Starter" },
{ value: "team", label: "Team" },
{ value: "enterprise", label: "Enterprise" },
]
export function Demo() {
return (
<div className="grid max-w-2xl gap-6 sm:grid-cols-2">
<Select
label="Country"
name="country"
items={COUNTRIES}
placeholder="Choose a country"
description="Where the invoice is addressed."
required
/>
<Select
label="Plan"
name="plan"
items={PLANS}
defaultValue="enterprise"
error="Enterprise needs a sales call first."
/>
</div>
)
}Source
These are the files the CLI copies into your project.
/**
* Select — a drop-down list built on Base UI's Select inside a Field, so the
* label, description and error are wired to the control the same way `Input`
* wires them.
*
* a11y notes worth keeping:
*
* - The trigger is a `combobox` button named by the label. Base UI handles the
* listbox roles, arrow keys, typeahead, Escape and returning focus.
* - `Field.Root invalid` marks the trigger `aria-invalid` and turns the border
* red for an error passed in from outside, such as one from a server.
* - "required" is spelled out in the label, as in `Input`.
* - A hidden `<input>` carries the value, so the field works in a plain form.
* - Options are 44px tall. The popup sits below the trigger rather than over
* it, so the label stays visible while the list is open.
*
* Not a client component: it creates no functions and holds no state. The
* placeholder is a `null` entry in Base UI's `items`, because a function child
* on `Select.Value` could not be rendered from a server component.
*
* Dependencies: @base-ui-components/react, lucide-react, react,
* @/lib/utils.
*/
import { Field } from "@base-ui-components/react/field"
import { Select as BaseSelect } from "@base-ui-components/react/select"
import { CheckIcon, ChevronDownIcon } from "lucide-react"
import type { ReactNode } from "react"
import { cn } from "@/lib/utils"
export interface SelectOption {
value: string
label: string
disabled?: boolean
}
export interface SelectProps {
/** Required. Use `hideLabel` if it should not be visible. */
label: ReactNode
/** Hides the label visually but keeps it for screen readers. */
hideLabel?: boolean
/** Help text below the field. It is linked to the field for you. */
description?: ReactNode
/** Error message. Marks the field invalid and is announced with it. */
error?: ReactNode
/** The options, in order. */
items: SelectOption[]
/** Shown in the trigger until something is chosen. */
placeholder?: string
/** Names the value in a submitted form. */
name?: string
/** The chosen value. Use with `onValueChange`. */
value?: string | null
/** The value on first render, when you do not control it. */
defaultValue?: string | null
/** Called with the new value when the user chooses an option. */
onValueChange?: (value: string | null) => void
required?: boolean
disabled?: boolean
id?: string
/** Classes for the outer Field.Root. */
className?: string
/** Classes for the trigger button. */
triggerClassName?: string
}
export function Select({
label,
hideLabel = false,
description,
error,
items,
placeholder = "Choose one",
name,
value,
defaultValue,
onValueChange,
required,
disabled,
id,
className,
triggerClassName,
}: SelectProps) {
// Base UI shows the label of the `null` item while nothing is chosen.
const labels = [{ value: null, label: placeholder }, ...items]
return (
<Field.Root
invalid={Boolean(error)}
className={cn("flex w-full flex-col gap-1.5", className)}
>
<Field.Label
className={cn("text-foreground text-sm font-medium", hideLabel && "sr-only")}
>
{label}
{required ? <span className="text-muted-foreground"> (required)</span> : null}
</Field.Label>
<BaseSelect.Root
items={labels}
name={name}
value={value}
defaultValue={defaultValue}
onValueChange={onValueChange}
required={required}
disabled={disabled}
id={id}
>
<BaseSelect.Trigger
className={cn(
"border-input bg-background text-foreground flex h-11 w-full cursor-pointer items-center justify-between gap-2 rounded-md border px-3.5 text-left text-base",
"duration-facade-fast ease-facade-out transition-[border-color,box-shadow]",
"focus-visible:ring-ring focus-visible:border-ring outline-none focus-visible:ring-2",
"data-[disabled]:cursor-not-allowed data-[disabled]:opacity-60",
"data-[invalid]:border-destructive data-[invalid]:focus-visible:ring-destructive",
"data-[placeholder]:text-muted-foreground",
triggerClassName,
)}
>
<BaseSelect.Value className="truncate" />
<BaseSelect.Icon className="text-muted-foreground flex shrink-0">
<ChevronDownIcon aria-hidden focusable="false" className="size-4" />
</BaseSelect.Icon>
</BaseSelect.Trigger>
<BaseSelect.Portal>
<BaseSelect.Positioner
sideOffset={6}
collisionPadding={16}
alignItemWithTrigger={false}
className="z-50"
>
<BaseSelect.Popup className="bg-popover text-popover-foreground duration-facade-fast ease-facade-out max-h-[var(--available-height)] w-[var(--anchor-width)] origin-[var(--transform-origin)] overflow-y-auto rounded-md border p-1 shadow-lg transition-[opacity,transform] data-[ending-style]:scale-95 data-[starting-style]:scale-95 data-[ending-style]:opacity-0 data-[starting-style]:opacity-0">
<BaseSelect.List className="flex flex-col gap-0.5">
{items.map((item) => (
<BaseSelect.Item
key={item.value}
value={item.value}
disabled={item.disabled}
className="data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground grid min-h-11 cursor-default select-none grid-cols-[1rem_minmax(0,1fr)] items-center gap-2 rounded-sm px-2 text-base outline-none data-[disabled]:cursor-not-allowed data-[disabled]:opacity-60"
>
<BaseSelect.ItemIndicator className="col-start-1 flex">
<CheckIcon aria-hidden focusable="false" className="size-4" />
</BaseSelect.ItemIndicator>
<BaseSelect.ItemText className="col-start-2">
{item.label}
</BaseSelect.ItemText>
</BaseSelect.Item>
))}
</BaseSelect.List>
</BaseSelect.Popup>
</BaseSelect.Positioner>
</BaseSelect.Portal>
</BaseSelect.Root>
{description ? (
<Field.Description className="text-muted-foreground text-pretty text-sm">
{description}
</Field.Description>
) : null}
{/* `match` shows an error from outside, which Base UI's own validity
state knows nothing about. */}
<Field.Error
className="text-destructive text-pretty text-sm"
match={Boolean(error) || undefined}
>
{error}
</Field.Error>
</Field.Root>
)
}Props
SelectProps
| Prop | Type | Default |
|---|---|---|
label*RequiredRequired. Use `hideLabel` if it should not be visible. | ReactNode | — |
hideLabelHides the label visually but keeps it for screen readers. | boolean | false |
descriptionHelp text below the field. It is linked to the field for you. | ReactNode | — |
errorError message. Marks the field invalid and is announced with it. | ReactNode | — |
items*RequiredThe options, in order. | SelectOption[] | — |
placeholderShown in the trigger until something is chosen. | string | "Choose one" |
nameNames the value in a submitted form. | string | — |
valueThe chosen value. Use with `onValueChange`. | string | null | — |
defaultValueThe value on first render, when you do not control it. | string | null | — |
onValueChangeCalled with the new value when the user chooses an option. | (value: string | null) => void | — |
required | boolean | — |
disabled | boolean | — |
id | string | — |
classNameClasses for the outer Field.Root. | string | — |
triggerClassNameClasses for the trigger button. | string | — |