docs / core / select

Select

one value from a closed list, revealed when you open its trigger. for a long set of known options; a few visible choices want SegmentedControl or Radio.

intensity · recedesrsc · client

provenance

authored in usva

layer

core / primitives

intensity

recedes · safe anywhere, including dense surfaces

composition

form rows next to Input, sharing the same height and focus ringfilter bars where the options are known and shortnot for two or three options. that is SegmentedControlnot for navigation. picking an option should never route

a11y

the trigger is a combobox, the popup a listbox of options · give a trigger with no visible label an aria-labeljest-axe

dependencies

@base-ui/react
live demo · try it out
customize
placeholdershown until a value is picked
defaultValuethe value chosen on mount
disableddims the trigger, blocks the popup
usagecopy
import { Select } from "usva/primitives/select";

<Select>
  <Select.Trigger aria-label="Fruit">
    <Select.Value placeholder="Pick a fruit" />
  </Select.Trigger>
  <Select.Content>
    <Select.Item value="apple">Apple</Select.Item>
    <Select.Item value="banana">Banana</Select.Item>
    <Select.Item value="cherry">Cherry</Select.Item>
  </Select.Content>
</Select>

props

proptypedefaultnotes
valueValue | nullthe controlled selection. pair with onValueChange.
defaultValueValue | nullstarting selection when uncontrolled.
onValueChange(value, eventDetails) => voidfires with the new value when the user picks one.
namestringthe form field name. a hidden input carries the value.
disabledbooleanfalsedims the trigger to 50% and blocks the popup.
Content sideOffsetnumber6the gap between trigger and listbox.

get it

npx shadcn add https://usva.build/r/select.jsoncopy
source · components/ui/select.tsxexactly what this command copiescopy
"use client";
import { Select as Base } from "@base-ui/react/select";
import * as React from "react";
import { cn } from "@/lib/utils";
import { useScopedTheme } from "./use-scoped-theme";

export type SelectProps<Value = string> = Base.Root.Props<Value>;

function SelectImpl<Value = string>(
  props: SelectProps<Value>,
  ref: React.Ref<HTMLInputElement>,
) {
  return <Base.Root inputRef={ref} {...props} />;
}

const SelectRoot = React.forwardRef(SelectImpl) as (<Value = string>(
  props: SelectProps<Value> & { ref?: React.Ref<HTMLInputElement> },
) => React.ReactElement) & {
  displayName?: string;
};
SelectRoot.displayName = "Select";

export const SelectTrigger = React.forwardRef<
  HTMLButtonElement,
  React.ComponentPropsWithoutRef<typeof Base.Trigger>
>(({ className, children, ...props }, ref) => (
  <Base.Trigger
    ref={ref}
    className={cn(
      "group flex h-10 w-full items-center justify-between gap-2 rounded-lg border border-border bg-surface px-3 text-sm text-ink outline-none",
      "transition-control duration-base ease-soft",
      "hover:border-border-strong",
      "focus-visible:border-transparent focus-visible:ring-focus",
      "data-[popup-open]:border-transparent data-[popup-open]:ring-focus",
      "aria-invalid:border-danger",
      "disabled:cursor-not-allowed disabled:opacity-50",
      className,
    )}
    {...props}
  >
    {children}
    <Base.Icon className="text-muted transition-control duration-base ease-soft group-data-[popup-open]:rotate-180">
      <ChevronIcon />
    </Base.Icon>
  </Base.Trigger>
));
SelectTrigger.displayName = "SelectTrigger";

export const SelectValue = Base.Value;

export const SelectContent = React.forwardRef<
  HTMLDivElement,
  React.ComponentPropsWithoutRef<typeof Base.Popup> & { sideOffset?: number }
>(({ className, sideOffset = 6, children, ...props }, ref) => {
  const [probe, theme] = useScopedTheme();

  return (
    <>
      <span ref={probe} hidden />
      <Base.Portal>
        <Base.Positioner
          data-theme={theme}
          sideOffset={sideOffset}
          className="z-dropdown"
        >
          <Base.Popup
            ref={ref}
            className={cn(
              "rim-light max-h-[min(24rem,var(--available-height))] min-w-[var(--anchor-width)] overflow-auto rounded-lg border border-border bg-surface-2 p-1 text-sm text-ink shadow-floating",
              "origin-[var(--transform-origin)] transition-enter duration-base ease-spring motion-reduce:transition-none motion-reduce:transform-none",
              "data-[starting-style]:translate-y-1 data-[starting-style]:scale-[0.97] data-[starting-style]:opacity-0 data-[starting-style]:blur-[2px]",
              "data-[ending-style]:scale-[0.98] data-[ending-style]:opacity-0 data-[ending-style]:duration-fast data-[ending-style]:ease-soft",
              className,
            )}
            {...props}
          >
            <Base.List>{children}</Base.List>
          </Base.Popup>
        </Base.Positioner>
      </Base.Portal>
    </>
  );
});
SelectContent.displayName = "SelectContent";

export type SelectItemProps = React.ComponentPropsWithoutRef<typeof Base.Item>;

export const SelectItem = React.forwardRef<HTMLDivElement, SelectItemProps>(
  ({ className, children, ...props }, ref) => (
    <Base.Item
      ref={ref}
      className={cn(
        "relative flex cursor-default select-none items-center justify-between gap-2 rounded-md px-2.5 py-2 text-muted outline-none",
        "ring-1 ring-inset ring-transparent",
        "before:absolute before:inset-0 before:rounded-[inherit] before:bg-ink/0 before:transition-tint before:duration-fast before:content-[''] motion-reduce:before:transition-none",
        "transition-control duration-fast ease-soft motion-reduce:transition-none",
        "data-[highlighted]:text-ink data-[highlighted]:before:bg-ink/5",
        "data-[selected]:bg-accent/[0.08] data-[selected]:text-ink data-[selected]:ring-accent/30",
        "data-[disabled]:cursor-not-allowed data-[disabled]:opacity-50",
        className,
      )}
      {...props}
    >
      <Base.ItemText className="relative">{children}</Base.ItemText>
      <Base.ItemIndicator className="relative text-accent">
        <CheckIcon />
      </Base.ItemIndicator>
    </Base.Item>
  ),
);
SelectItem.displayName = "SelectItem";

export const Select = Object.assign(SelectRoot, {
  Trigger: SelectTrigger,
  Value: SelectValue,
  Content: SelectContent,
  Item: SelectItem,
});

function ChevronIcon() {
  return (
    <svg
      viewBox="0 0 12 12"
      fill="none"
      stroke="currentColor"
      strokeWidth={1.5}
      strokeLinecap="round"
      strokeLinejoin="round"
      className="h-3.5 w-3.5"
      aria-hidden="true"
    >
      <path d="M3 4.5 6 7.5 9 4.5" />
    </svg>
  );
}

function CheckIcon() {
  return (
    <svg
      viewBox="0 0 12 12"
      fill="none"
      stroke="currentColor"
      strokeWidth={2}
      strokeLinecap="round"
      strokeLinejoin="round"
      className="h-3.5 w-3.5"
      aria-hidden="true"
    >
      <path d="M2.5 6.5 5 9l4.5-5.5" />
    </svg>
  );
}