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 />| State | Trigger | Notes |
|---|---|---|
| Placeholder | --border-thin --color-grey-200, white | Placeholder text in --color-text-disabled |
| Selected | Same, value in 14px --color-text-primary | Long labels are cut with an ellipsis |
| Focus and open | 1px --color-primary-500 and --shadow-focus | The caret turns 180° while the list is open |
| Error | 1px --color-red, focus ring --shadow-error-focus | Message 12px medium --color-red |
| Disabled | No border, --color-grey-50 | Label, 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
| Prop | Type | Default | Notes |
|---|---|---|---|
label | ReactNode | "Form label" | Names the trigger through aria-labelledby. |
placeholder | string | "Placeholder" | Shown when no option matches value. |
options | { value: string, label: string }[] | [] | Options cannot be disabled or grouped, and have no icon or description. |
value | string | "" | The selected option's value. |
onChange | (value: string) => void | Called with the picked option's value. Picking the current option calls it again. | |
error | boolean | false | Red border on the trigger. |
errorMessage | string | "" | Shown under the trigger only when error is true. |
disabled | boolean | false | |
className | string | Added 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"withmenuitemoptions), not alistbox. 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 withouthtmlFor, so clicking the label does not focus or open the Dropdown. errordoes not setaria-invalidon the trigger, and the message is not linked to it. The message hasrole="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-labellands 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.