Skip to content

SearchField

Terminal window
$ npx shadcn@latest add https://lacodda.github.io/dowel/r/search-field.json
See it liveEmpty with its shortcut showing, and with a query and the clear button in its place.

An Input with three small things the products kept not doing, so they are done once here rather than four times badly.

A magnifier, so the field is recognisable before it is read.

A way to clear it that is not “select all and delete” — and one a keyboard can reach. This is the half that goes wrong: the cross is usually a decorative <span> with an onClick, which Tab skips and a screen reader does not announce, so the only way to empty the field is the one the mouse knows. Here it is a real <button> with a name, and clearing puts the cursor back in the field, because clearing and then having to click the field again is half a feature.

The shortcut that focuses it, shown in the field rather than learned. The same array both draws the hint and binds the key, so what is shown and what works cannot drift apart.

It is what tells a browser to offer previous queries, and what makes Escape clear the field on the platforms where that is the convention. The browser’s own clear button is hidden, because it is drawn in the operating system’s chrome and cannot be made to match — the same reason the line does not use a native <select>.

clearLabel is the clear button. Leave it out and no button is drawn, which is the right shape for a field that filters as you type and is emptied by other means. There is no separate showClear flag, because a flag can disagree with the label, and a button announced as nothing is worse than no button at all.

shortcut is the same idea: give it one and it is both shown and bound; leave it out and neither happens.

The hint and the clear button share the right edge, so the hint gives way as soon as there is something to clear.

The shortcut does not fire while someone is typing

Section titled “The shortcut does not fire while someone is typing”

It is useShortcut underneath, with its default intact: Mod+K typed into another field belongs to that field. It focuses and selects, so the shortcut replaces a stale query rather than appending to it.

import { SearchField } from '@/components/ui/search-field'
<SearchField
value={query}
onValueChange={setQuery}
aria-label={t('search')}
placeholder={t('searchPlaceholder')}
clearLabel={t('clear')}
shortcut={['Mod', 'K']}
/>

SearchField when what is typed filters something the reader is already looking at — a table, a list of files, a page of results. There is nothing to choose from a popup; the query is the whole interaction.

Combobox when the typing ends in a choice: the reader is picking one of a set, and the query is only how they find it.

CommandPalette when it is the same box for the whole application, opened by a shortcut from anywhere.

Prop Type Default
value string Required and controlled — a search box that owns its own text cannot be cleared by the thing that owns the results
onValueChange (value: string) => void Told the new query on every keystroke
clearLabel string What the clear button is called. No default; without it no button is drawn
shortcut string[] As ['Mod', 'K']. Shown at the right of the field, and bound
ref Ref<HTMLInputElement> Reaches the input, not the wrapper
className string Merged onto the wrapper so the caller wins a conflict

Everything else is passed to the <input>, so placeholder, aria-label, disabled, name and the rest work as usual. type is not among them: it is always search.