Design system

Dropdown

A labelled form field that picks one value from a list.

import { Dropdown } from "@/ui";

Dropdown is the form select. Its trigger looks like a Text input, and its list is the product's Dropdown menu (Base UI, in src/components/shadcn/dropdown-menu.jsx). Use it inside forms and settings. For a menu of actions on a button, use Dropdown menu directly.

Usage

Pass options as { value, label } objects and keep the chosen value in state. Unlike Text input, onChange receives the option's value, not an event, so you can pass a state setter straight in.

const FORM_TARGETS = [
  { value: "hubspot", label: "HubSpot · create or update the contact" },
  { value: "email", label: "Email the workspace owner" },
  { value: "both", label: "HubSpot and email" },
];

const [target, setTarget] = useState("hubspot");

<Dropdown
  label="Send form submissions to"
  options={FORM_TARGETS}
  value={target}
  onChange={setTarget}
/>

The chosen option shows a blue check (--color-primary-500) in the list. The trigger fills the width of its container, and the list is at least as wide as the trigger and at most 360px.

No value yet

When value is empty, or does not match any option, the trigger shows the placeholder in --color-text-disabled. Write the placeholder as an instruction: "Choose a channel". Lists taller than 280px scroll.

const [channel, setChannel] = useState("");

<Dropdown
  label="Channel"
  placeholder="Choose a channel"
  options={CHANNELS}
  value={channel}
  onChange={setChannel}
/>

Always pass both label and placeholder. They default to the literal text "Form label" and "Placeholder".

There is no way to clear a choice once one is made. If "none" is a valid answer, add it as an option, such as { value: "none", label: "Don't send anywhere" }.

Validation

error turns the trigger border red and errorMessage shows under it. Show the error after someone has tried to submit, not before they have had a chance to pick.

<Dropdown
  label="Count pipeline from"
  placeholder="Choose a stage"
  options={STAGES}
  value={stage}
  onChange={setStage}
  error={!stage}
  errorMessage="Choose the stage that counts as pipeline."
/>

States

<Dropdown label="Placeholder" placeholder="Choose a period" options={periods} value="" />
<Dropdown label="Selected" options={periods} value="q4" />
<Dropdown label="Error" placeholder="Choose a period" options={periods} value="" error errorMessage="Choose a reporting period." />
<Dropdown label="Disabled" options={periods} value="q4" disabled />
StateTriggerNotes
Placeholder--border-thin --color-grey-200, whitePlaceholder text in --color-text-disabled
SelectedSame, value in 14px --color-text-primaryLong labels are cut with an ellipsis
Focus and open1px --color-primary-500 and --shadow-focusThe caret turns 180° while the list is open
Error1px --color-red, focus ring --shadow-error-focusMessage 12px medium --color-red
DisabledNo border, --color-grey-50Label, value and caret in --color-text-disabled / --color-icon-disabled. The list cannot open.

The trigger is 44px tall with 10px by 16px padding and --radius-md corners, the same as Text input, so the two line up in a form. The list opens 4px below the trigger: a white panel with a 1px --color-grey-200 border, 12px corners, 6px padding and a soft shadow. Options are 36px rows of 14px text, highlighted with --color-primary-50 on hover or keyboard focus.

An errored Dropdown keeps its red border while open but gets the blue --shadow-focus ring around it, because the open style and the error style each set half of the look. It reads as a mix of the two states.

Props

PropTypeDefaultNotes
labelReactNode"Form label"Names the trigger through aria-labelledby.
placeholderstring"Placeholder"Shown when no option matches value.
options{ value: string, label: string }[][]Options cannot be disabled or grouped, and have no icon or description.
valuestring""The selected option's value.
onChange(value: string) => voidCalled with the picked option's value. Picking the current option calls it again.
errorbooleanfalseRed border on the trigger.
errorMessagestring""Shown under the trigger only when error is true.
disabledbooleanfalse
classNamestringAdded to the wrapper <div>. Use for layout only.

Any other prop is spread onto the wrapper <div>, not the trigger button. An aria-label or id passed to Dropdown therefore does not name or identify the control.

Accessibility

  • The trigger is a native <button> named by the visible label. Enter, Space or the Down arrow opens the list; arrow keys move between options; Enter picks; Escape closes and returns focus to the trigger. Typing a letter jumps to the first option starting with it.
  • The list is a menu (role="menu" with menuitem options), not a listbox. Screen readers announce it as a menu, and the chosen option is shown with a check icon only, so the selection is not announced inside the list. The trigger text does say the current value.
  • The label is a <label> element without htmlFor, so clicking the label does not focus or open the Dropdown.
  • error does not set aria-invalid on the trigger, and the message is not linked to it. The message has role="alert", so it is announced when it first appears, but not when someone later tabs back to the field.
  • If the design has no visible label, there is currently no clean way to name the trigger, since aria-label lands on the wrapper. Keep a visible label.

Do and don't

  • Do use a Dropdown for one choice from four or more options, or when the options are long.
  • Do keep option labels short and parallel: "Q3 2026", "Q4 2026", not a sentence each.
  • Don't use a Dropdown for two or three options that people should compare at a glance. Use Radio so every option is visible.
  • Don't use a Dropdown to trigger actions such as "Duplicate" or "Delete". Use Dropdown menu on a button.
  • Don't build a select from a TextInput and a popover. If Dropdown is missing something you need, such as search or multi-select, raise it so the component gains it once.

On this page