Select

A select is a field where you choose one value from a list. It opens from a button that looks like an Input, and shows the choice once made.

import { Field, FieldLabel } from "@/components/ui/field";
import {
  Select,
  SelectContent,
  SelectGroup,
  SelectItem,
  SelectLabel,
  SelectSeparator,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select";

const fruits = [
  { label: "Select a fruit", value: null },
  { label: "Apple", value: "apple" },
  { label: "Banana", value: "banana" },
  { label: "Orange", value: "orange" },
];

const plans = [
  { label: "Free", value: "free" },
  { label: "Pro", value: "pro" },
  { label: "Team", value: "team" },
  { label: "Enterprise", value: "enterprise" },
];

export default function SelectDemo() {
  return (
    <div className="flex w-full max-w-xs flex-col gap-6">
      <Field name="fruit">
        <FieldLabel>Fruit</FieldLabel>
        <Select items={fruits}>
          <SelectTrigger>
            <SelectValue />
          </SelectTrigger>
          <SelectContent>
            <SelectGroup>
              <SelectLabel>Fruits</SelectLabel>
              {fruits.slice(1).map((fruit) => (
                <SelectItem key={fruit.value} value={fruit.value}>
                  {fruit.label}
                </SelectItem>
              ))}
            </SelectGroup>
            <SelectSeparator />
            <SelectItem value="grape" disabled>
              Grape (out of stock)
            </SelectItem>
          </SelectContent>
        </Select>
      </Field>
      <Field name="plan">
        <FieldLabel>Plan</FieldLabel>
        <Select items={plans} defaultValue="pro">
          <SelectTrigger>
            <SelectValue />
          </SelectTrigger>
          <SelectContent>
            {plans.map((plan) => (
              <SelectItem key={plan.value} value={plan.value}>
                {plan.label}
              </SelectItem>
            ))}
          </SelectContent>
        </Select>
      </Field>
      <Field name="region" disabled>
        <FieldLabel>Region</FieldLabel>
        <Select items={[{ label: "Europe", value: "eu" }]} defaultValue="eu" disabled>
          <SelectTrigger>
            <SelectValue />
          </SelectTrigger>
          <SelectContent>
            <SelectItem value="eu">Europe</SelectItem>
          </SelectContent>
        </Select>
      </Field>
    </div>
  );
}

The code

The component is a single file, read from the repository when the site is built. Press Show code to see all of it.

import type { ComponentProps } from "react";
import { Check, ChevronsUpDown } from "lucide-react";
import { Select as BaseSelect } from "@base-ui/react/select";
import { cx } from "@/lib/cx";
import { controlStyles, popupMotion, popupSurface } from "@/lib/styles";
import type { WithClass } from "@/lib/types";

// Pass `items` (a list of { label, value }) to Select so the trigger can show the label of the
// chosen value, then render one SelectItem for each.
export const Select = BaseSelect.Root;

// The shown choice, in the secondary color while nothing is chosen yet (the placeholder).
export function SelectValue({ className, ...props }: WithClass<ComponentProps<typeof BaseSelect.Value>>) {
  return <BaseSelect.Value className={cx("truncate data-[placeholder]:text-text-secondary", className)} {...props} />;
}

// The button that opens the list. It looks like an Input, with a pair of arrows on the right.
export function SelectTrigger({ className, children, ...props }: WithClass<ComponentProps<typeof BaseSelect.Trigger>>) {
  return (
    <BaseSelect.Trigger
      className={cx(
        "inline-flex h-9 cursor-pointer items-center justify-between gap-2 text-left",
        controlStyles,
        "data-[popup-open]:border-text",
        className,
      )}
      {...props}
    >
      {children}
      <BaseSelect.Icon>
        <ChevronsUpDown aria-hidden className="size-4 text-text-secondary" />
      </BaseSelect.Icon>
    </BaseSelect.Trigger>
  );
}

// The list opens below the trigger and is at least as wide as it.
export function SelectContent({
  sideOffset = 4,
  className,
  children,
  ...props
}: WithClass<ComponentProps<typeof BaseSelect.Popup>> & Pick<ComponentProps<typeof BaseSelect.Positioner>, "sideOffset">) {
  return (
    <BaseSelect.Portal>
      <BaseSelect.Positioner sideOffset={sideOffset} alignItemWithTrigger={false} className="z-50">
        <BaseSelect.Popup
          className={cx(popupSurface, popupMotion, "min-w-[var(--anchor-width)]", className)}
          {...props}
        >
          <BaseSelect.List className="max-h-[var(--available-height)] overflow-y-auto p-1">{children}</BaseSelect.List>
        </BaseSelect.Popup>
      </BaseSelect.Positioner>
    </BaseSelect.Portal>
  );
}

export function SelectItem({ className, children, ...props }: WithClass<ComponentProps<typeof BaseSelect.Item>>) {
  return (
    <BaseSelect.Item
      className={cx(
        "grid cursor-default grid-cols-[1rem_1fr] items-center gap-2 rounded-sm py-1.5 pr-3 pl-2 text-base text-text outline-hidden select-none data-[highlighted]:bg-text/10 data-[disabled]:cursor-not-allowed data-[disabled]:text-text-secondary",
        className,
      )}
      {...props}
    >
      <BaseSelect.ItemIndicator className="col-start-1">
        <Check aria-hidden className="size-4" />
      </BaseSelect.ItemIndicator>
      <BaseSelect.ItemText className="col-start-2">{children}</BaseSelect.ItemText>
    </BaseSelect.Item>
  );
}

export const SelectGroup = BaseSelect.Group;

export function SelectLabel({ className, ...props }: WithClass<ComponentProps<typeof BaseSelect.GroupLabel>>) {
  return <BaseSelect.GroupLabel className={cx("px-2 py-1.5 text-sm font-medium text-text-secondary", className)} {...props} />;
}

export function SelectSeparator({ className, ...props }: WithClass<ComponentProps<typeof BaseSelect.Separator>>) {
  return <BaseSelect.Separator className={cx("-mx-1 my-1 h-px bg-border", className)} {...props} />;
}

Props

PropTypeDefaultWhat it does
items{ label, value }[]—On Select. The choices, so the button can show the label of the chosen value. Pass an item with a null value for a placeholder.
valuestring—On each SelectItem. What the item stands for. Required.
defaultValuestring—On Select. The value that starts chosen, when it manages its own state.
onValueChangefunction—On Select. Called with the newly chosen value.
disabledbooleanfalseOn Select or on one SelectItem. Stops it from being opened or chosen.
namestring—On Select, or on the Field around it. The name the value is submitted under in a form.

Every other prop goes to the Base UI Select underneath. Wrap it in a Field to give it a label.

The thinking

A select is for choosing one value out of too many to show as a row of radios. It saves space, at the cost of one extra click.

  • Built on Base UI: Base UI handles the open state, the highlight, type-ahead, the placement and the hidden form input.
  • Not a native select: A native select can't be styled, so the open list would look different on every system. This one looks like the rest of the library.
  • Use items: Give Select an items list so the button can show the label of the chosen value, such as Pro, rather than its value.
  • Select or Dropdown Menu: A select picks a value for a field. A Dropdown Menu runs commands.

Interface design

  • Looks like an Input: The button shares the Input's height, border and focus ring, with a pair of up and down arrows after the text.
  • Placeholder: While nothing is chosen, the text is the quiet secondary color, like an Input's placeholder.
  • List: The shared overlay surface, at least as wide as the button, opening below it. The chosen row has a tick on the left.
  • Disabled: A flat surface with a muted border, as for a disabled Input.

Interaction

  • Open: Click the button, or press Enter, Space or the Down arrow when it has focus.
  • Keyboard: The arrow keys move the highlight, and typing a letter jumps to a row that starts with it. Enter or Space chooses. A disabled row can be highlighted but not chosen.
  • Close: Choosing a row, Escape, or a click outside. Focus goes back to the button.
  • Open state: While the list is open, the border of the button brightens.

Empathy

  • Labeled: Wrap it in a Field with a FieldLabel so the button is announced with a name.
  • Announced as a listbox: The list reports its options, and which one is selected.
  • Few choices?: With five or fewer options, a RadioGroup shows them all at once and takes fewer clicks.