one of a list, opened over the field: options with icons, in groups
npx shadcn add @ag/selectAlready installed? Preview an update
npx shadcn add @ag/select --dry-runYour installed source stays unchanged until you update it. How updates work
Latest changes · 2026-10-12
- Fixed: inside a
Settingwhose value is inherited, a named select posts""with its form (nothing is set here); it posted its internal inherit marker. - Changed: a long list fades its cut edges where there is more (
ag-edge-fade-y); it cut hard at the popup's edge.
also called dropdown
shelf controls
uses scrims, field-context, material, portal, scroll-area, setting-context
used by components, date-picker
the demo above, verbatim
"use client"
import { Film, Image, Music } from "lucide-react"
import * as React from "react"
import { Sample } from "@/app/samples"
import { Label } from "@/registry/base-nova/ui/label"
import { MaterialProvider } from "@/registry/base-nova/ui/material"
import {
Select,
type SelectGroup,
type SelectOption,
} from "@/registry/base-nova/ui/select"
import { Setting } from "@/registry/base-nova/ui/setting"
// #region kinds
const KINDS: SelectOption[] = [
{ value: "video", label: "video", icon: <Film /> },
{ value: "photo", label: "photo", icon: <Image /> },
{ value: "audio", label: "audio", icon: <Music /> },
]
// #endregion
// #region zones
const ZONES: SelectGroup[] = [
{
label: "europe",
options: [
{ value: "madrid", label: "Madrid", description: "UTC+2 in summer" },
{ value: "lisbon", label: "Lisbon", description: "UTC+1 in summer" },
{ value: "berlin", label: "Berlin", description: "UTC+2 in summer" },
],
},
{
label: "americas",
options: [
{ value: "mexico", label: "Mexico City", description: "UTC−6" },
{ value: "new-york", label: "New York", description: "UTC−4 in summer" },
{ value: "sao-paulo", label: "São Paulo", description: "UTC−3" },
],
},
]
// #endregion
// #region inherited
/** A choice that falls back to the workspace's: its list leads with an entry for
* "inherit", the field a ghost while it is chosen; picking a kind sets it here,
* picking the inherit entry (or ×, ⌥-click, Delete) clears it back. */
function Inherited() {
const [kind, setKind] = React.useState<string | undefined>(undefined)
return (
<Setting
label="kind"
value={kind}
fallback={{ value: "photo", from: "the workspace" }}
onValueChange={setKind}
>
{(s) => (
<Select
aria-label="kind"
options={KINDS}
value={s.value}
onValueChange={(v) => v && s.pin(v)}
/>
)}
</Setting>
)
}
// #endregion
// #region fields
function Fields() {
const [kind, setKind] = React.useState<string | null>("video")
return (
<div className="flex flex-col gap-3">
<Select
aria-label="kind"
options={KINDS}
value={kind}
onValueChange={setKind}
/>
<div className="flex flex-col gap-1.5">
<Label htmlFor="zone">time zone</Label>
<Select id="zone" options={ZONES} placeholder="choose a city" />
</div>
</div>
)
}
// #endregion
export default function SelectDemo() {
return (
<div className="space-y-8">
<Sample
name="materials"
title="plain and glass"
description="the list opens over the field, the chosen row under the hand; type a city's first letters to jump to it."
with="kinds zones fields"
>
<div className="grid gap-4 sm:grid-cols-2">
<div className="p-6">
<Fields />
</div>
<MaterialProvider material="glass">
<div className="rounded-xl bg-[url(/lightbox/field.jpg)] bg-cover bg-center p-6 text-white">
<Fields />
</div>
</MaterialProvider>
</div>
</Sample>
<Sample
name="sizes"
glance
title="the input's heights, wrong"
with="kinds"
>
<div className="flex flex-wrap items-center gap-3">
<Select
size="sm"
aria-label="small"
options={KINDS}
defaultValue="photo"
/>
<Select aria-label="medium" options={KINDS} defaultValue="photo" />
<Select
size="lg"
aria-label="large"
options={KINDS}
defaultValue="photo"
/>
<Select
aria-label="kind"
aria-invalid
options={KINDS}
placeholder="required"
/>
</div>
</Sample>
<Sample
name="inherited"
title="following a default"
with="kinds inherited"
>
<div className="grid gap-4 sm:grid-cols-2">
<div className="p-6">
<Inherited />
</div>
<MaterialProvider material="glass" scheme="dark">
<div className="rounded-xl bg-[url(/lightbox/field.jpg)] bg-cover bg-center p-6">
<Inherited />
</div>
</MaterialProvider>
</div>
</Sample>
</div>
)
}the source, every file an install writes
"use client"
import { Select as BaseSelect } from "@base-ui/react/select"
import { Check, ChevronsUpDown } from "lucide-react"
import * as React from "react"
import { cn } from "@/lib/utils"
import { useField } from "@/registry/base-nova/ui/field-context"
import {
type Material,
type Scheme,
useMaterial,
useSurfaceScheme,
} from "@/registry/base-nova/ui/material"
import { glassVariants, schemeOf } from "@/registry/base-nova/ui/material-glass"
import { usePortalTarget } from "@/registry/base-nova/ui/portal"
import { ScrollArea } from "@/registry/base-nova/ui/scroll-area"
import {
GHOST_EDGE,
GLASS_GHOST,
useSetting,
} from "@/registry/base-nova/ui/setting-context"
// One of a list, chosen from a list that opens (Base UI's select: the list opens over
// the field with the chosen row on it, the way a desktop's pop-up button does; arrow
// keys and typing the first letters move through it, Enter chooses, Escape closes; a
// hidden input submits it with a form). Given as data, the way `menu` takes its verbs:
// `options` is a flat list, or groups of them, each group named and parted by a line.
// The chosen row carries a tick.
//
// The field is the input's well (plain) or a pill of glass, at the input's heights, so
// a select sits in a form's row as a field does; red when wrong (`aria-invalid`, or a
// Base UI Field that judged it). Its list is the page's popover surface plain, the
// glass menu's pane in glass. Inside a `Setting` the list leads with an entry for
// "inherit" (the fallback's words, "lark's, follows it" under them), chosen while
// nothing is set here, choosing it clears; inherited, the field is a ghost (its
// well dashed and unfilled, glass: the material's ghost).
//
// Where we part from shadcn: data, not seven parts to compose (a select's rows are
// always the same few things: words, perhaps a glyph, perhaps a line under them), and
// the list opens over the field rather than below it.
export type SelectSize = "sm" | "md" | "lg"
export type SelectOption<V extends string = string> = {
value: V
label: React.ReactNode
/** What typing finds it by, when the label is not plain text. */
text?: string
/** A glyph before its words. */
icon?: React.ReactNode
/** A quieter line under its words. */
description?: React.ReactNode
disabled?: boolean
}
export type SelectGroup<V extends string = string> = {
label: string
options: readonly SelectOption<V>[]
}
const isGroup = <V extends string>(
o: SelectOption<V> | SelectGroup<V>,
): o is SelectGroup<V> => "options" in o
const FIELD: Record<SelectSize, string> = {
sm: "h-7 gap-1.5 text-[12px] [&_svg]:size-3",
md: "h-8 gap-2 text-[13px] [&_svg]:size-3.5",
lg: "h-10 gap-2 text-sm [&_svg]:size-4",
}
const PAD: Record<SelectSize, string> = {
sm: "pr-1.5 pl-2",
md: "pr-2 pl-2.5",
lg: "pr-2.5 pl-3",
}
/** The glass pill's padding and radius, half its height. */
const GLASS_PAD: Record<SelectSize, string> = {
sm: "pr-2 pl-3 [--glass-radius:14px]",
md: "pr-2.5 pl-3.5 [--glass-radius:16px]",
lg: "pr-3 pl-4 [--glass-radius:20px]",
}
const PLAIN_FIELD =
"rounded-md bg-foreground/[0.04] ring-1 ring-foreground/12 ring-inset transition-[box-shadow,background-color] hover:ring-foreground/22 data-popup-open:bg-foreground/[0.06] data-popup-open:ring-foreground/40 aria-invalid:ring-tone-bad/70 data-invalid:ring-tone-bad/70"
const GLASS_FIELD =
"hover:[--glass-rim:1.3]! data-popup-open:[--glass-rim:1.7]! aria-invalid:[--glass-stain:var(--tone-bad)]! aria-invalid:[--glass-rim-color:var(--tone-bad)]! aria-invalid:[--glass-rim:2.2]! data-invalid:[--glass-stain:var(--tone-bad)]! data-invalid:[--glass-rim-color:var(--tone-bad)]! data-invalid:[--glass-rim:2.2]!"
/** The field's face, for anything that opens from a field (a select's list, a date
* picker's calendar): the input's well plain, a pill of glass, at the input's
* heights; lit while open (`data-popup-open`), red when wrong (`aria-invalid`). One
* face, so every field that opens something looks like the next. */
export function selectFieldFace(m: Material, size: SelectSize): string {
return cn(
"inline-flex min-w-36 cursor-pointer items-center justify-between whitespace-nowrap outline-none data-disabled:cursor-not-allowed data-disabled:opacity-50",
FIELD[size],
m === "glass"
? [glassVariants({ shape: "surface" }), GLASS_FIELD, GLASS_PAD[size]]
: [PLAIN_FIELD, PAD[size]],
)
}
/** Inherited (inside a `Setting`, nothing set here): the field a ghost of the value
* that applies. Plain, its well unfilled and dashed, the words a step back; glass,
* the material's ghost. */
const PLAIN_GHOST = cn(
"relative bg-transparent ring-0 data-popup-open:bg-transparent",
GHOST_EDGE,
)
/** The value of the list's own entry for "inherit" (inside a `Setting`): what the
* fallback says, under its source's name. No option may use it. */
const INHERIT = "__setting-inherit__"
const PLAIN_LIST =
"rounded-lg border border-border bg-popover text-popover-foreground shadow-md"
const GLASS_LIST = "[--glass-radius:--theme(--radius-xl)] [--glass-blur:14px]"
/** A row: the tick's column, then its glyph and words. */
const ROW =
"grid cursor-default grid-cols-[0.875rem_1fr] items-center gap-2 px-2 py-1.5 text-[13px] outline-none select-none data-disabled:opacity-45 [&_svg]:size-3.5 [&_svg]:shrink-0"
const PLAIN_ROW =
"rounded-md text-foreground/85 data-highlighted:bg-foreground/8 data-highlighted:text-foreground"
const GLASS_ROW = "rounded-[8px] data-highlighted:bg-current/10"
export type SelectProps<V extends string> = {
options: readonly SelectOption<V>[] | readonly SelectGroup<V>[]
value?: V | null
defaultValue?: V | null
onValueChange?: (value: V | null) => void
/** Shown while nothing is chosen. */
placeholder?: React.ReactNode
/** Its accessible name, where no visible Label names it (`id` + `htmlFor`). */
"aria-label"?: string
"aria-invalid"?: boolean
/** What else says something about it (a field's description and error). */
"aria-describedby"?: string
id?: string
name?: string
required?: boolean
disabled?: boolean
readOnly?: boolean
size?: SelectSize
material?: Material
/** The colour scheme it stands in (material): default the nearest provider's.
* Its list, portalled out of the page, wears it too. */
scheme?: Scheme
/** Where its list is drawn. Default: the nearest `PortalTarget` (portal: a
* player declares its layer, so a list opens inside it in fullscreen), else the
* body. */
container?: React.ComponentProps<typeof BaseSelect.Portal>["container"]
/** The field's classes (its width most often). */
className?: string
}
export function Select<V extends string>({
options,
value,
defaultValue,
onValueChange,
placeholder,
"aria-label": label,
"aria-invalid": invalid,
"aria-describedby": describedBy,
id,
name,
required,
disabled,
readOnly,
size = "md",
material,
scheme,
container,
className,
}: SelectProps<V>) {
const m = useMaterial(material)
const target = usePortalTarget()
// Over a picture the field is read by the part of it under the field; the list,
// opening over the field, wears the same.
const [s, here] = useSurfaceScheme<HTMLButtonElement>(scheme)
const field = useField()
// Inside a setting: the list leads with an entry for "inherit" (what the
// fallback says, under its source's name), chosen while nothing is set here and
// choosing it clears; the field a ghost while inherited; read-only while locked.
const setting = useSetting()
const ghost = setting?.inherited ?? false
const given: readonly SelectGroup<V>[] = options.some(isGroup)
? (options as readonly SelectGroup<V>[])
: [{ label: "", options: options as readonly SelectOption<V>[] }]
const fallback = setting?.fallback
const applies = fallback
? given.flatMap((g) => g.options).find((o) => o.value === fallback.value)
: undefined
if (fallback && !applies)
throw new Error(
`select: the setting's fallback "${String(fallback.value)}" is none of its options`,
)
if (given.some((g) => g.options.some((o) => o.value === INHERIT)))
throw new Error(`select: "${INHERIT}" is the setting's own entry`)
// The inherit entry is typed as the list's other rows; its value never reaches
// `onValueChange`.
const groups: readonly SelectGroup<V>[] =
fallback && applies
? [
{
label: "",
options: [
{
...applies,
value: INHERIT as V,
disabled: false,
description: `${fallback.from}'s, follows it`,
},
],
},
...given,
]
: given
// What the field shows for a value: its row's words.
const items = React.useMemo(
() =>
groups.flatMap((g) =>
g.options.map((o) => ({ value: o.value, label: o.label })),
),
[groups],
)
const glass = m === "glass"
return (
<BaseSelect.Root
items={items}
value={ghost ? (INHERIT as V) : value}
defaultValue={defaultValue}
onValueChange={(next) => {
if (next === INHERIT) return setting?.clear?.()
onValueChange?.(next as V | null)
}}
// Inside a field: its wiring, what is passed here winning; read-only while
// the form sends (the list does not open, the focus stays), or while a
// setting is locked.
id={id ?? field?.wiring.id}
// Inherited, nothing is set here and the form says so ("", below): the list's
// value is then the inherit entry, which is no value to send.
name={ghost ? undefined : name}
required={required ?? field?.wiring["aria-required"]}
disabled={disabled ?? field?.wiring.disabled}
readOnly={readOnly || field?.readOnly || setting?.readOnly}
>
<BaseSelect.Trigger
ref={here}
data-slot="select"
data-size={size}
data-material={m}
data-inherited={ghost ? "" : undefined}
aria-label={label}
aria-labelledby={label ? undefined : field?.labelId}
aria-invalid={invalid || field?.invalid || undefined}
aria-describedby={
[describedBy ?? field?.wiring["aria-describedby"], setting?.says]
.filter(Boolean)
.join(" ") || undefined
}
className={cn(
selectFieldFace(m, size),
ghost && (glass ? GLASS_GHOST : PLAIN_GHOST),
schemeOf(s),
className,
)}
>
<BaseSelect.Value
data-slot="select-value"
placeholder={placeholder}
className={cn(
"min-w-0 truncate data-placeholder:opacity-45",
!glass && (ghost ? "text-foreground/60" : "text-foreground"),
glass && ghost && "opacity-65",
)}
/>
<BaseSelect.Icon className="flex shrink-0 opacity-50">
<ChevronsUpDown aria-hidden />
</BaseSelect.Icon>
</BaseSelect.Trigger>
{ghost && name && <input type="hidden" name={name} value="" />}
<BaseSelect.Portal container={container ?? target}>
<BaseSelect.Positioner className="z-50 outline-none select-none">
<BaseSelect.Popup
data-slot="select-list"
data-material={m}
className={cn(
"min-w-(--anchor-width) origin-(--transform-origin) overflow-hidden outline-none data-ending-style:scale-[0.98] data-ending-style:opacity-0 data-starting-style:scale-[0.98] data-starting-style:opacity-0 data-[side=none]:data-starting-style:scale-100 data-[side=none]:data-starting-style:opacity-100",
schemeOf(s),
glass
? [
glassVariants({ shape: "surface" }),
GLASS_LIST,
"[--glass-moves:opacity_100ms_var(--ease-out),scale_100ms_var(--ease-out)] data-[side=none]:data-starting-style:[--glass-moves:opacity_0s,scale_0s]",
]
: [
PLAIN_LIST,
"transition-[opacity,scale] duration-100 ease-out data-[side=none]:data-starting-style:transition-none motion-reduce:transition-none",
],
)}
>
{/* The browser's bar never shows (the registry's scrollbar rule): a list
this short is scrolled by the wheel and the arrow keys, its cut
edges fading where there is more (scroll-area). */}
<ScrollArea
render={<BaseSelect.List />}
className="max-h-(--available-height) scroll-py-1 p-1"
>
{groups.map((g, i) => (
<React.Fragment key={g.label || i}>
{i > 0 && (
<BaseSelect.Separator
className={cn(
"mx-1.5 my-1 h-px",
glass ? "bg-current/10" : "bg-foreground/10",
)}
/>
)}
<BaseSelect.Group>
{g.label && (
<BaseSelect.GroupLabel
className={cn(
"px-2 pt-1.5 pb-1 pl-[1.875rem] text-[11px]",
glass ? "text-current/55" : "text-foreground/45",
)}
>
{g.label}
</BaseSelect.GroupLabel>
)}
{g.options.map((o) => (
<BaseSelect.Item
key={o.value}
value={o.value}
label={
o.text ??
(typeof o.label === "string" ? o.label : undefined)
}
disabled={o.disabled}
data-slot="select-option"
className={cn(ROW, glass ? GLASS_ROW : PLAIN_ROW)}
>
<BaseSelect.ItemIndicator className="col-start-1 flex">
<Check aria-hidden />
</BaseSelect.ItemIndicator>
<span className="col-start-2 flex min-w-0 items-center gap-2">
{o.icon}
<span className="min-w-0">
<BaseSelect.ItemText className="block truncate">
{o.label}
</BaseSelect.ItemText>
{o.description && (
<span
className={cn(
"block text-[11px] leading-4",
glass
? "text-current/55"
: "text-foreground/50",
)}
>
{o.description}
</span>
)}
</span>
</span>
</BaseSelect.Item>
))}
</BaseSelect.Group>
</React.Fragment>
))}
</ScrollArea>
</BaseSelect.Popup>
</BaseSelect.Positioner>
</BaseSelect.Portal>
</BaseSelect.Root>
)
}