Components/Form
Search
Search is the field a reader types a question into. What goes in is a query the page is expected to answer rather than a value it will keep, so the words the reader chooses are what narrow the results they get back. The prompt inside it is what says how far that reach goes.
import { Search } from '@growth-protocol-ai/alpha';When to use
Reach for Search wherever a surface holds more than a reader can scan — a table, a library, a directory — and let the prompt name what it reaches, because a reader who cannot tell how far the field looks will not trust an empty result. Decide early whether results arrive as the reader types or only when they ask for them; that choice is the one the rest of the surface has to be built around.
When not to use
Do not reach for Search to collect something the reader is contributing — a name, an address, a title — however alike the two look on a page. That is a text input, and the test is what happens on Enter: a search hands the page a query and expects an answer back, while a text input holds a value until the form around it is submitted. A field that drops a panel of live suggestions is a combobox, and is not this component either.
size2 values
Size is geometry only — it never changes what the field means. Both heights are the button’s, the dropdown’s and the text field’s, so a toolbar that mixes them lines up row by row.
| size | When to use |
|---|---|
regular | 36px. Toolbars, filter bars and page headers. |
large | 52px. Search-led heroes and touch-first surfaces. |
rounding2 values
The one shape choice the text field does not offer, and it is a claim about the surface around the control rather than about the control: the states, the border, the glass and the clear × are identical either way. The pill is for a search that owns its row; anywhere it sits among other controls — a dropdown, a filter, a row of toolbar actions — stay square.
| rounding | When to use |
|---|---|
default | --radius-md, 12px. The field shape shared with the button, the dropdown and the text input. Reach for it whenever the search sits among other controls. |
pill | --radius-full. For a search that owns its visual context — a search-led hero, a dashboard’s primary search, a command palette. Beside rectangular siblings the rounded ends read as a chip rather than a field. |
| State | When to use |
|---|---|
Empty | The resting frame: --border-default around a placeholder in --text-disabled, the glass grey, and no clear ×. Focus lights the same brand border that content does, so the frame alone stops saying whether the field holds a query — the glass and the × are what still do. |
Filled | The glass lifts to --text-typical and the clear × appears with the first character and leaves with the last, because filled is read off the value rather than set. The --button-primary border and the inset shadow are focus’s too, painted at the same specificity and later in the sheet — what filled adds is that they survive the blur: a field holding a query says so. |
Display (read-only) | The default frame around a query someone already ran, with no × and no brand border, and the glass lit because there is content under it. Not a disabled control — nothing about it is off, it simply is not taking input — so it stays focusable, selectable and submitted. |
Disabled, empty | A --surface-depth-2 fill and no border at all: blank space rather than a field waiting for a question. Skipped by the keyboard, left out of the submitted form, and deaf to Escape. |
Disabled, filled | The same fill, but a --text-disabled border keeps the shape of a field, so a locked query still reads as one. The glass goes grey and the clear × is gone in both disabled renderings. |
The placeholder is always "Search…" or "Search <scope>…" — "Search portfolios, stories, reports…" tells the reader what the field reaches. Never leave it blank, and never write an example value: "e.g. jane@growthprotocol.ai" is right for an email field and wrong here, because it describes an answer rather than the question.
role="search" is a landmark, so a page with more than one wants label on each ("Site search", "Search this table"). A single primary search can leave it off. The field’s own name is separate: pass aria-label, or an id with a <label htmlFor> pointing at it.
The × carries no visible text, so clearLabel is its accessible name. Name the search in it when a page has several: "Clear table filter", not "Clear". Clearing then returns focus to the field it just emptied, because the button leaves with the content that justified it.
Escape clears only while the field holds something. Empty, the key is left alone, which is what lets a Search sit inside a Modal without swallowing the Escape that closes it.
Cmd/Ctrl+K is a convention, not a prop. Alpha ships no global shortcut — a component cannot know which search on the page is the primary one, and two that both claimed the key would fight. Hold a ref, bind the key in your own effect, and call focus() on it.
Suggestions, recent queries and command palettes are not props on this one. A search that drops a panel of live results is a combobox and will arrive as its own component; a Cmd/K overlay is a Search composed inside a Modal. This is the field, and only the field.
The default width is the dropdown trigger’s, so a search and a selector line up in a filter bar without arithmetic. Override it through className when the row wants something else, and give every control in that row the same treatment.
Asking the question
onSearch fires on Enter and on nothing else. Clearing — with the ×, or with Escape — reports through onChange and never through onSearch, because emptying a search is not the reader asking for results; re-run the query yourself if an empty search should show everything. Give the callback and Enter stops here rather than also submitting a surrounding form; leave it off and the form keeps the key.
Controlled value
value with onChange, where the state lives in yours. Both ways of clearing report through onChange exactly as a keystroke does, so a field that ignores the event will not clear — which is the one way a controlled search can look broken.
| Prop | Type | Default | Notes |
|---|---|---|---|
size | "regular" | "large" | "regular" | 36px or 52px. Geometry only — never changes intent. |
rounding | "default" | "pill" | "default" | 12px or fully round. Nothing else changes with it — see the rounding table for when each one is right. |
value | string | number | — | Controlled value. Pair it with onChange. |
defaultValue | string | number | — | Initial value when uncontrolled. Filled is derived from it, so a search that starts with a term starts with the brand border and the ×. |
onSearch | (value: string) => void | — | The query, on Enter. Give it one and Enter is the component’s: it stops there rather than also submitting a surrounding form. |
onChange | (event: ChangeEvent<HTMLInputElement>) => void | — | Every keystroke, and once more with an empty value on clear. This is the one to watch for search-as-you-type; onSearch is the deliberate ask. |
onKeyDown | (event: KeyboardEvent<HTMLInputElement>) => void | — | Offered every key before the component spends Enter or Escape, so preventDefault in it takes either back. |
label | string | — | Accessible name for the role="search" landmark on the frame. Not the field’s own name — aria-label is that, and it lands on the input. |
placeholder | string | — | Shown while the field is empty, in --text-disabled. Always "Search…" or "Search <scope>…" — see the guidelines. |
disabled | boolean | false | Non-interactive, and repainted rather than faded. |
readOnly | boolean | false | The Display state. Content, no editing, no ×. |
clearable | boolean | true | The clear button and the Escape shortcut, together. Turn it off where clearing is not an action worth offering — a search whose scope is set elsewhere, or one that must always hold a term. |
clearLabel | string | "Clear" | Accessible name for the ×, which carries no visible text. |
children | never | — | A void <input> takes none. The field’s own label goes beside the frame, not inside it. |