← ui

select

ui

one of a list, opened over the field: options with icons, in groups

npx shadcn add @ag/select
Already installed? Preview an update
npx shadcn add @ag/select --dry-run

Your installed source stays unchanged until you update it. How updates work

Latest changes · 2026-10-12
  • Fixed: inside a Setting whose 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.
Updated 2026-10-12 · release notes

also called dropdown

shelf controls

uses scrims, field-context, material, portal, scroll-area, setting-context

used by components, date-picker

the demo above, verbatim

select.demo.tsx
"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

select.tsx
"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>
  )
}