Components/Form
Dropdown
Dropdowns let a reader pick one value from a list that stays out of the way until it is needed, or several when it is set up for that. The trigger shows the current choice and the list appears only when asked for, which is what suits them to sets of options too long for a page to show all at once.
import { Dropdown } from '@growth-protocol-ai/alpha';When to use
Reach for a dropdown when the answer is one of a set the product already knows and the reader’s job is only to say which one — a market, a period, an owner. It reads best with the field named beside it and the trigger left to say what is currently chosen, which is also the arrangement that survives the list growing.
When not to use
Do not hide a handful of options behind a trigger: choices a reader should be able to compare at a glance belong in a radio group, which shows them all and costs no click. A value the reader has to type is a text input, and a question the page is expected to answer is a search field. A dropdown also holds one value, so a filter that accepts several answers is a set of checkboxes rather than a longer panel.
emphasis2 values
The same axis the button calls emphasis, and what picks it is how much chrome the surrounding UI can carry rather than how important the choice is. Neither changes the panel: it is its own surface, with its own border, either way.
| emphasis | When to use |
|---|---|
solidFlat | The default — the button’s Outlined Solid Flat, with a chevron. It shares the neutral surface, border and height of a button, so it sits in a form or a filter bar without a visual mismatch. |
ghost | Empty at rest, filling on interaction. For a selector inside a toolbar, a table footer or a line of text, where a border would be more chrome than the choice deserves. |
size2 values
Size is geometry only — it never changes what the control means. Both heights are the button’s and the text input’s, so a filter bar that mixes the three lines up control by control.
| size | When to use |
|---|---|
regular | 36px. Forms, filter bars and toolbars. |
large | 52px. Hero filters and touch-first surfaces. |
| State | When to use |
|---|---|
Multi-select | Under multiple the rows become checkbox rows, a select-all sits above them, and the panel stays open on a pick so a reader can build a set without reopening it. The trigger then reads the chosen labels in the order they were chosen, and the selection is an array in every one of the three props that carry it. |
Nothing selected | The trigger shows its placeholder, in the same text colour a chosen value would take rather than a dimmed one — a control waiting for an answer is not a control that is off. Opening from here marks no row as selected, so the highlight starts on the first one and the arrow keys have somewhere to go. |
A value chosen | The trigger takes the option’s label and the panel closes on the same tick. The chosen row carries aria-selected, and reopening lands the highlight on it rather than at the top, which is how the reader sees what they picked last time without the list marking it twice. |
Without the search row | The panel is a list and nothing else, and focus goes to the list itself rather than to a field. Reach for it when there are few enough rows that something to filter them with is noise; Home and End are then swallowed rather than scrolling the page out from under the panel. |
Nothing to show | An empty set of options and a query that matches nothing render the same way: the rows go, and one line of text takes their place. It sits outside the list and is a live region, so a reader who is typing hears the rows run out instead of being left in silence. |
Disabled | The fill is swapped and the label goes to --text-disabled while the border stays, so a disabled trigger still reads as the same control rather than a faded one. Ghost takes that fill too rather than disappearing, and the panel cannot be opened at all. |
The trigger holds a value, not a label: its accessible name is whatever it currently says. Name the field beside it — "Market", "Sort by" — with a real label or an aria-label, because a control announced as "Portugal" tells a screen-reader user nothing about what they are choosing.
Give an unselected dropdown a placeholder. An empty trigger reads as a control that failed to load rather than one waiting for an answer.
Enter or Space opens the panel and focus lands inside it, in the search field or on the list where there is none. The arrow keys move the highlight, Enter picks, and Escape closes without choosing. One row is ever lit, because the pointer moves the same highlight the keyboard does.
Nothing here traps focus. Tab leaves the panel and closes it, which is also why the list itself is tabbable when no search field is there to hold focus first.
Keep the width. The panel measures the trigger, so a class that widens one widens both — and a row of dropdowns sharing a width reads as one set of filters rather than three controls.
Give every option a stable value. It is the identity onValueChange reports and the name value and defaultValue point at, so a value derived from an array index moves the selection when the list reorders.
Do not blank emptyLabel. It is the only thing that tells a reader whose query matched nothing that the list came back empty rather than broken; a panel with nothing in it says neither.
Controlled selection
value with onValueChange, and open with onOpenChange, where both live in your state rather than the component’s. Every route out reports through those handlers — a row picked, Escape, a click outside, focus leaving the panel — so a controlled dropdown that ignores one of them is a dropdown that will not close.
What the trigger shows
leading puts a glyph or a chip before the value, and children pin the trigger’s text so it names the choice instead of echoing it — a menu-style trigger that keeps saying "Sort by" whatever is selected. Whether leading content fades with the label when the control is off is decided by the artwork rather than by a prop: a glyph drawn in currentColor follows the text, and a flag or an avatar keeps its own colours because it is part of the value.
| Prop | Type | Default | Notes |
|---|---|---|---|
emphasis | "solidFlat" | "ghost" | "solidFlat" | How much chrome the trigger carries. See the table above. |
size | "regular" | "large" | "regular" | 36px or 52px. Geometry only — never changes intent. |
options | readonly DropdownOption[] | [] | The rows in the panel. An option is a value — the identity onValueChange reports — and a label, which is what the row shows, what the trigger takes once it is picked, and what the search matches: substring, case-insensitive. |
value | string | readonly string[] | — | Controlled selection, by option value; an array under multiple. Pair it with onValueChange. |
defaultValue | string | readonly string[] | — | Initial selection when uncontrolled; an array under multiple. |
onValueChange | ((value: string) => void) | ((value: readonly string[]) => void) | — | Fires on click and on Enter. Single-select reports the value and closes the panel on the same tick; multi-select reports the whole selection and leaves it open. |
placeholder | string | — | What the trigger says while nothing is selected. Without one it says nothing at all. |
children | ReactNode | — | Stands in for the trigger’s value, so it no longer follows the selection. Leave it out and the trigger shows the chosen label, then the placeholder. |
leading | ReactNode | — | Glyph or chip before the value. Sized to 20px like the chevron; a larger identity chip that sets its own size with a class wins. |
multiple | boolean | false | Turns the rows into checkbox rows, adds the select-all above them, and keeps the panel open on a pick. It also retypes value, defaultValue and onValueChange as arrays — they are a union, so the type refuses the two halves mixed. |
selectAllLabel | string | "Select all" | Label on the select-all row, which multiple draws above the choices whenever the search has left more than one on screen. Ignored without it. |
searchable | boolean | true | The search row above the list. Turn it off for a handful of rows, where a field to filter three of them is noise. |
searchPlaceholder | string | "Search" | Placeholder and accessible name for the search field, which carries no visible label. Name what is being searched: "Search markets". |
emptyLabel | string | "No results" | The line that replaces the rows when there are none to show. Announced, so write it as a sentence a reader can act on. |
open | boolean | — | Controlled open state of the panel. Pair it with onOpenChange. |
onOpenChange | (open: boolean) => void | — | Every open and every route out: a row picked, Escape, a click outside, focus leaving the panel. |
disabled | boolean | false | Non-interactive, and repainted rather than faded. |
dropdown-panel | [data-slot] | — | The panel, which is portalled to the end of the document rather than nested in the trigger — so it is reached from the root of your stylesheet, not by descent. It is painted at the modal’s layer, so a dropdown inside a dialog is not lost under it. |