Pattern
Search
Overview
Search is how a person narrows a set of content by typing — a page of results, a filtered table,
a list of suggestions to pick from. This is a composition pattern, not a component: Monogem
ships no <Search> primitive. A search experience is built from
Input or Combobox / Command, plus a leading
icon, a clear action, and whatever region already renders results — never a new primitive that
wraps them.
Anatomy
Plain search field
<div> relative wrapper, same width as the field
├─ Icon / spinner absolute, leading — Search glyph, or a spinning
│ loader while a search is in flight
├─ <input type="search"> the real Input primitive, padding extended to
│ clear the icon and the clear button
└─ Clear button absolute, trailing, flush to the field's right
edge — shown only once the field has a value
- Icon / spinner — a single Lucide
Searchglyph at the "inline with text" size (Icons —--scale-4, 16px), colored--muted-foreground,aria-hidden="true". While a search is in flight it is replaced by a spinningLoader2at the same size and color — the same "spinner replaces the leading icon slot" idiom Button already uses for its ownloadingstate. - The field — the real Input primitive,
type="search", at its default size only. Its ownpx-3base padding is overridden withpl-8 pr-10so the icon and clear button sit visually inside the bordered box instead of beside it — aclassNameoverride, not a change to Input (Input ships no leading/trailing icon slot in its baseline; this pattern works around that entirely in composing markup, the same way Command's own disclosure button reuses Input's visual recipe without reaching into Input's contract). - Clear button — Button
variant="ghost"size="icon", unmodified (40px square, matching Input's own default height exactly), positioned flush to the field's trailing edge (rounded-l-none rounded-r-smso its outer corner matches Input's--radius-sminstead of Button's own--radius-md). Rendered only while the field has a value; clicking or activating it clears the query and returns focus to the field — the same "return focus to the field" rule the Combobox pattern already uses after a commit or dismiss.
Combobox search
Unchanged from the already-documented Combobox composition:
<Popover> → Input-styled disclosure button → <Command> → <CommandInput> (which ships its own
built-in leading Search glyph) → <CommandList> → <CommandItem>s. Search adds no new part
here — see Loading (searching) state for the one gap this pattern
fills in.
Live Example
Plain search field — results render below, in the page’s own region
Showing all 8 articles.
- Getting started with Monogem
- Design tokens overview
- Button component guide
- Building accessible forms
- Empty states pattern
- Combobox vs. Select
- Theming with CSS variables
- Icon usage guidelines
Disabled — one opacity value dims the whole composition, not the field alone
Combobox search — live suggestions in a popover, one committed value
Nothing committed yet.
Code Example
"use client";
import * as React from "react";
import { Loader2, Search, SearchX, X } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Command,
CommandEmpty,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
/**
* Live Search example — Search is a composition PATTERN, not a component:
* there is no `<Search>` import and no `components/ui/search.tsx`. Both
* sections below simulate an async source (a `setTimeout` delay) so the
* loading state is genuinely reachable, per docs/patterns/search.md.
*
* - Plain search: a real <Input type="search"> with a hand-composed leading
* icon/spinner and a flush trailing clear Button (unmodified `ghost` +
* `icon`), matching results rendered below in a plain list; empty results
* fall back to the Empty States no-results composition (a local,
* unexported `NoResults` helper, the same role `EmptyState` plays in
* empty-state-showcase.tsx).
* - Disabled: a static composition demonstrating the one-opacity-value rule
* the doc's States table names — dimming lives on the wrapper, not Input
* alone, so the icon and clear button dim (and stop responding) with it.
* - Combobox search: the already-documented Combobox pattern
* (Popover + Command, `shouldFilter={false}`) styled for search, with the
* loading message carried in Command's own `CommandEmpty` slot instead of
* a new part.
*/
const ARTICLES = [
"Getting started with Monogem",
"Design tokens overview",
"Button component guide",
"Building accessible forms",
"Empty states pattern",
"Combobox vs. Select",
"Theming with CSS variables",
"Icon usage guidelines",
];
/**
* Simulates a debounced remote search against a static list. `loading` is
* derived (never set directly in the effect body, per this repo's
* `react-hooks/set-state-in-effect` rule) by comparing the query the last
* committed results answered against the current one — they differ for as
* long as a request is "in flight".
*/
function useDelayedResults(query: string, items: string[], delayMs = 400) {
const [committed, setCommitted] = React.useState(() => ({
query,
results: items,
}));
React.useEffect(() => {
const id = window.setTimeout(() => {
const needle = query.trim().toLowerCase();
setCommitted({
query,
results:
needle === ""
? items
: items.filter((item) => item.toLowerCase().includes(needle)),
});
}, delayMs);
return () => window.clearTimeout(id);
}, [query, items, delayMs]);
return { results: committed.results, loading: committed.query !== query };
}
function NoResults({ query, onClear }: { query: string; onClear: () => void }) {
return (
<div className="flex max-w-sm flex-col items-center py-6 text-center">
<SearchX
aria-hidden="true"
className="mb-4 size-6 text-muted-foreground [stroke-width:var(--icon-stroke-width)]"
/>
<div className="flex flex-col gap-1">
<h4 className="text-xl font-semibold leading-snug">
No results for “{query}”
</h4>
<p className="text-sm leading-normal text-muted-foreground">
Try a different search term.
</p>
</div>
<div className="mt-6">
<Button onClick={onClear}>Clear search</Button>
</div>
</div>
);
}
function PlainSearchExample() {
const [query, setQuery] = React.useState("");
const { results, loading } = useDelayedResults(query, ARTICLES);
const inputRef = React.useRef<HTMLInputElement>(null);
function clear() {
setQuery("");
inputRef.current?.focus();
}
const status = loading
? "Searching…"
: query
? results.length > 0
? `${results.length} result${results.length === 1 ? "" : "s"} for “${query}”`
: `No results for “${query}”.`
: `Showing all ${ARTICLES.length} articles.`;
return (
<section className="flex flex-col gap-3">
<h3 className="text-sm font-medium leading-snug text-muted-foreground">
Plain search field — results render below, in the page’s own region
</h3>
<div className="flex w-full max-w-sm flex-col gap-1.5">
<Label htmlFor="pattern-search-input">Search articles</Label>
<div className="relative" aria-busy={loading}>
{loading ? (
<Loader2
aria-hidden="true"
className="absolute left-3 top-1/2 size-4 -translate-y-1/2 animate-spin text-muted-foreground [stroke-width:var(--icon-stroke-width)]"
/>
) : (
<Search
aria-hidden="true"
className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground [stroke-width:var(--icon-stroke-width)]"
/>
)}
<Input
ref={inputRef}
id="pattern-search-input"
type="search"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Search articles…"
className="pl-8 pr-10 [&::-webkit-search-cancel-button]:appearance-none"
/>
{query ? (
<Button
type="button"
variant="ghost"
size="icon"
aria-label="Clear search"
onClick={clear}
className="absolute right-0 top-0 rounded-l-none rounded-r-sm"
>
<X aria-hidden="true" className="[stroke-width:var(--icon-stroke-width)]" />
</Button>
) : null}
</div>
</div>
<p className="text-sm leading-normal text-muted-foreground" aria-live="polite">
{status}
</p>
{!loading && query && results.length === 0 ? (
<NoResults query={query} onClear={clear} />
) : (
<ul className="flex max-w-sm flex-col gap-1">
{results.map((item) => (
<li
key={item}
className="rounded-sm px-3 py-2 text-sm leading-normal text-foreground"
>
{item}
</li>
))}
</ul>
)}
</section>
);
}
/**
* Static disabled composition — matches Input's own "one opacity value
* applies to the field as a unit" rule (input.md#disabled-treatment): the
* dimming lives on the wrapper, not on Input alone, so the icon and the
* clear button dim with it instead of a caller getting a visually mixed
* control whose query can still be changed through Clear. Each descendant's
* own `disabled:opacity-50` is neutralized (`disabled:opacity-100`) so the
* wrapper's opacity isn't compounded on top of it, and the clear button
* carries a real `disabled` attribute so it's genuinely non-interactive, not
* just dimmed.
*/
function DisabledSearchExample() {
return (
<section className="flex flex-col gap-3">
<h3 className="text-sm font-medium leading-snug text-muted-foreground">
Disabled — one opacity value dims the whole composition, not the field
alone
</h3>
<div className="flex w-full max-w-sm flex-col gap-1.5">
<Label htmlFor="pattern-search-disabled" className="opacity-50">
Search articles
</Label>
<div className="relative opacity-50">
<Search
aria-hidden="true"
className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground [stroke-width:var(--icon-stroke-width)]"
/>
<Input
id="pattern-search-disabled"
type="search"
disabled
defaultValue="design tokens"
className="pl-8 pr-10 disabled:opacity-100 [&::-webkit-search-cancel-button]:appearance-none"
/>
<Button
type="button"
variant="ghost"
size="icon"
disabled
aria-label="Clear search"
className="absolute right-0 top-0 rounded-l-none rounded-r-sm disabled:opacity-100"
>
<X aria-hidden="true" className="[stroke-width:var(--icon-stroke-width)]" />
</Button>
</div>
</div>
</section>
);
}
function ComboboxSearchExample() {
const [open, setOpen] = React.useState(false);
const [query, setQuery] = React.useState("");
const [selected, setSelected] = React.useState<string | null>(null);
const { results, loading } = useDelayedResults(query, ARTICLES);
const triggerRef = React.useRef<HTMLButtonElement>(null);
return (
<section className="flex flex-col gap-3">
<h3 className="text-sm font-medium leading-snug text-muted-foreground">
Combobox search — live suggestions in a popover, one committed value
</h3>
<Label htmlFor="pattern-search-combobox-trigger">Search documentation</Label>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger>
<button
ref={triggerRef}
id="pattern-search-combobox-trigger"
type="button"
className={cn(
"flex h-10 w-64 items-center gap-2 rounded-sm border-[length:var(--scale-px)] border-solid border-input bg-background px-3 text-sm leading-normal text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
!selected && "text-muted-foreground",
)}
>
<Search
aria-hidden="true"
className="size-4 shrink-0 [stroke-width:var(--icon-stroke-width)]"
/>
{selected ?? "Search documentation…"}
</button>
</PopoverTrigger>
<PopoverContent aria-label="Documentation search" className="w-64 p-0">
<Command shouldFilter={false} value={query} onValueChange={setQuery}>
<CommandInput
placeholder="Type to search…"
aria-label="Search documentation"
/>
<CommandList aria-label="Results">
<CommandEmpty>
{loading ? "Searching…" : `No results for “${query}”.`}
</CommandEmpty>
{!loading &&
results.map((item) => (
<CommandItem
key={item}
value={item}
onSelect={(value) => {
setSelected(value);
setOpen(false);
triggerRef.current?.focus();
}}
>
{item}
</CommandItem>
))}
</CommandList>
</Command>
</PopoverContent>
</Popover>
<p className="text-sm leading-normal text-muted-foreground" aria-live="polite">
{selected ? `Committed: ${selected}` : "Nothing committed yet."}
</p>
</section>
);
}
export function SearchShowcase() {
return (
<div className="flex flex-col gap-10">
<PlainSearchExample />
<DisabledSearchExample />
<ComboboxSearchExample />
</div>
);
}States
| State | Look |
|---|---|
| Empty | Search icon only, no clear button, placeholder text shown |
| Has a value | Clear button appears at the trailing edge |
| Searching | Leading icon replaced by a spinning Loader2; aria-busy="true" on the field's wrapper (plain) or zero rendered rows + CommandEmpty showing "Searching…" (Combobox) |
| Has results | Results render in the caller's own region (plain) or as <CommandItem>s (Combobox) |
| No results | Empty States no-results composition (plain) or <CommandEmpty>'s "No results" copy (Combobox) |
| Disabled | The wrapper's opacity dims the icon, the field, and the clear button as one unit (see Disabled state) — the same "one opacity value applies to the field as a unit" rule Input uses for itself. The clear button also carries a real disabled attribute, not just reduced opacity. |
Loading (searching) state
Neither Input nor Command ships a built-in
loading / error / debounce affordance — Command's async hook (shouldFilter={false}) leaves
that to the caller in Version 1. Search answers it at
the composition level, without changing either primitive:
- Plain field — swap the leading
Searchicon for a spinningLoader2(animate-spin, same--scale-4size and--muted-foregroundcolor) for as long as a search is in flight, and setaria-busy="true"on the field's wrapper<div>— the same "mark the busy region" rule Table and Skeleton already use for their own loading states. - Combobox search — render zero
<CommandItem>s while loading.<CommandEmpty>already renders exactly when the list has no matching rows and is already anaria-live="polite"region (command.md#anatomy) — Search reuses that existing slot for the loading message itself ({loading ? "Searching…" : "No results found."}) instead of adding a new part to Command. This works becauseshouldFilter={false}already hands result-rendering to the caller: while a request is in flight the caller simply renders nothing,CommandEmptyfires on schedule, and once results arrive the caller renders<CommandItem>s andCommandEmptyhides itself the same way it always does.
No-results
Both variants hand off to the already-documented Empty States no-results contrast — name the query, offer a clear action, never a create action:
- Plain field — render Empty States' no-results composition (icon optional, heading
No results for "{query}", a primary Button reading "Clear search") in the same region the results would otherwise occupy. - Combobox search —
<CommandEmpty>already carries this copy inline (No results for "{query}".); its ownaria-live="polite"region is the announcement, so no separate Empty States composition is layered on top inside the popover.
Usage Guidance
Plain search field vs. Combobox search
Two different jobs both start with a search box, and the acceptance criterion for this pattern is keeping them visibly distinct:
| Plain search field | Combobox search | |
|---|---|---|
| Purpose | Filter or query content shown elsewhere on the page — a results list, a filtered table, a grid of cards | Pick one item from a set of live-narrowed suggestions |
| Where results render | The page's own region, outside the field | Inline, in a popover anchored under the field |
| What happens on commit | Nothing is "chosen" — the query just narrows what's already visible, on every keystroke or on submit | Selecting a row commits one value, closes the surface, and returns focus to the trigger |
| Field itself | A real <input type="search">, always visible, always editable | The existing documented Combobox composition — an Input-styled disclosure button opens a <Popover> holding <Command>; the actual typing happens on <CommandInput> inside the surface |
| Ships as | Hand-composed markup (below) — no primitive | Command's already-shipped Combobox pattern, unchanged |
| Reach for it when | The result set isn't a bounded list of "things to pick" — a documentation search, a table filter, a product search results page | The list is enumerable and typing is there to narrow which one the user wants, the same test Command already uses for reaching for it over Select |
This is the same fork Command already draws between itself and Select / Dropdown Menu — Search adds no new axis, it just applies that existing line to search specifically and adds the missing loading / no-results answer for it (see Loading (searching) state and No-results).
Composition — plain search field
const [query, setQuery] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
<div className="relative">
<Search
aria-hidden="true"
className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground [stroke-width:var(--icon-stroke-width)]"
/>
<Input
ref={inputRef}
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search…"
className="pl-8 pr-10 [&::-webkit-search-cancel-button]:appearance-none"
/>
{query && (
<Button
type="button"
variant="ghost"
size="icon"
aria-label="Clear search"
onClick={() => {
setQuery("");
inputRef.current?.focus();
}}
className="absolute right-0 top-0 rounded-l-none rounded-r-sm"
>
<X aria-hidden="true" className="[stroke-width:var(--icon-stroke-width)]" />
</Button>
)}
</div>
[&::-webkit-search-cancel-button]:appearance-none hides the native WebKit clear affordance
Input already names for type="search" — without it, a WebKit
browser would show two clear controls stacked on top of each other. Firefox and Chromium-non-WebKit
ship no such built-in control, so this is a no-op there. Every icon in the composition —
including the spinner — carries [stroke-width:var(--icon-stroke-width)]: Icons
requires it on every glyph, with no per-instance exception.
Whether the query filters live (on every onChange) or only on submit (wrapping the field
in a <form onSubmit> so Enter triggers it) is a caller decision this pattern doesn't force
either way — both are valid plain-search behavior. Debouncing a live query before it reaches a
remote source is also the caller's concern, the same as Command's
async shouldFilter={false} hook.
Disabled state
Disabling a search field means disabling the whole composition, not just the input — Input's
own disabled treatment already establishes this: "one opacity value applies to the field as a unit
… not the control alone" (Input). A caller who only
adds disabled to <Input> gets a visually mixed control (a dimmed field next to a full-opacity,
still-clickable clear button that can change the query anyway), so this pattern puts the dimming on
the wrapper, not on Input alone:
<div className={cn("relative", disabled && "opacity-50")}>
<Search aria-hidden="true" className="… [stroke-width:var(--icon-stroke-width)]" />
<Input
disabled={disabled}
className="pl-8 pr-10 disabled:opacity-100 [&::-webkit-search-cancel-button]:appearance-none"
/>
<Button
variant="ghost"
size="icon"
disabled={disabled}
aria-label="Clear search"
className="absolute right-0 top-0 rounded-l-none rounded-r-sm disabled:opacity-100"
>
<X aria-hidden="true" className="[stroke-width:var(--icon-stroke-width)]" />
</Button>
</div>
The wrapper's opacity-50 dims the icon, the field, and the clear button as one unit; each
descendant's own disabled:opacity-50 is neutralized (disabled:opacity-100) so the wrapper's
opacity isn't compounded on top of it (two stacked 50% opacities would read as 25%, noticeably
fainter than every other disabled control in the system). The clear button also gets a real
disabled attribute — dimming it is not enough on its own, since a caller could otherwise still
click it and change the query on a field that's supposed to be inert.
Composition — Combobox search
Use the Combobox composition exactly as
documented, styled for search (a Search-led disclosure button reading "Search…" until a value is
committed). For an async / remote source, pass shouldFilter={false} on <Command> and render
only the caller's already-narrowed results as <CommandItem>s — the one addition this pattern
makes to that existing contract is the loading state below.
Results presentation
Search defines no result-item shape, ranking, or data model. What it does specify is where results go and how their state is signaled, reusing pieces that already exist:
- Plain field results render in whatever the surrounding page already uses for that content —
a bare list, a Table (
loadingsetsaria-busy="true"on the table the same way this pattern's field does), a grid of Cards. If the result shape is known ahead of time, an initial load can show Skeleton placeholders instead of the spinner; a search re-run against an already-populated list uses the inline spinner described above instead of replacing visible content with skeletons (Skeleton's own guidance already warns against leaving skeletons on screen once real content exists). - A visually-present result count or status — a
<p aria-live="polite">line reporting "12 results for 'query'" (or "No results for 'query'.") — is the recommended way to announce a result-set change to a screen-reader user without navigation, the exact idiom the Command live example already uses to announce a committed value.
Spacing
Every value is an existing Spacing or Icons recipe:
| Gap | Token | px | Rationale |
|---|---|---|---|
| Icon's inset from the field's left edge | --scale-3 | 12 | Reuses Input's own inline padding value ("Input / button padding") |
| Icon → typed text | --scale-1 | 4 | Spacing's own "icon-to-label gap" recipe, reused here for icon-to-text |
Field's left padding (pl-8) | --scale-8 | 32 | The sum of the above two plus the icon's own --scale-4 width |
Field's right padding (pr-10) | --scale-10 | 40 | Clears the clear button's own footprint — Button's unmodified icon size |
Tokens
Search composes Input, Button, Command, the Icons foundation's existing size recipes, and the Empty States pattern's no-results contrast; it introduces no component-specific styling values.
| Token | Where used |
|---|---|
--scale-3 | Leading icon's inset from the field's left edge |
--scale-4 | Leading icon / spinner size ("inline with text," Icons) |
--scale-1 | Gap between the icon and the field's typed text |
--scale-8 (pl-8) | Field's left padding |
--scale-10 (pr-10, and the clear button's own size-10 footprint) | Field's right padding |
--muted-foreground | Icon / spinner color, placeholder text, result-count status text |
--input / --background / --foreground / --ring | The field itself — Input's own states, unchanged |
--radius-sm | The clear button's outer corner, overridden to match the field instead of Button's own --radius-md |
--icon-stroke-width | Every glyph in the composition — Search, the loading spinner, the clear icon, the Combobox trigger's Search icon (Icons) |
| Button's own tokens | Clear button — ghost variant, icon size, unchanged |
| Command's own tokens | Combobox search surface — unchanged, see Command |
Do / Don’t
Do
- Reach for a plain field when results render elsewhere on the page; reach for Combobox search only when the point of typing is choosing one item from what appears.
- Show the clear button only once the field has a value, and return focus to the field after using it.
- Announce a result-count or no-results change with
aria-live="polite"— the field alone updating visually isn't enough for a screen-reader user. - Reuse Empty States' no-results contrast (name the query, offer clear, never create) for a plain field's empty result set.
- Debounce a query before it reaches a remote source in your own code — this pattern defines the display states only.
- Dim the icon, the field, and the clear button together when disabled — put the opacity on the wrapper, not on Input alone (see Disabled state).
Don’t
- Build a
<Search>primitive that wraps Input/Button/Command — the same rejection Empty States and Form make for their own wrapper components. - Disable only
<Input>and leave the clear button interactive — a caller could still change the query on a field that's supposed to be inert. - Hide a WebKit
type="search"field's native cancel button and forget to also render your own — the two must not both be present, and neither absent. - Reach for Combobox search just because a list is long — per Command, it earns its place only when the user is genuinely picking one item, not filtering a page.
- Leave the field editable-but-silent while a request is in flight — always signal it (spinner +
aria-busy, orCommandEmpty's loading copy). - Size the field anything other than Input's default in V1.
Accessibility
- Labeling. Every plain search field needs an accessible name — either a paired
<Label>or anaria-labelwhen no visible label fits the layout (a compact toolbar search, for instance). The Combobox variant already inherits this from Command —CommandInputneeds aplaceholderand anaria-label. - Keyboard. The plain field is a native
<input>— it takes every native text-editing key for free.Escapeis deliberately not intercepted by this pattern on the plain field (unlike Select / Popover / Command's system-wide "Escape closes immediately" rule, there is no surface here for Escape to close); some browsers already clear atype="search"field on Escape as native behavior, which this pattern doesn't fight. The clear button is a real<button>—Tabreaches it only while it's rendered, andEnter/Spaceactivate it like any Button. The Combobox variant's keyboard model is entirely Command's own, unchanged. - Result announcements. A result-count / no-results status line needs
aria-live="polite"(see Results presentation) so a screen-reader user learns the list changed without moving focus — the same live-region contract Empty States andCommandEmptyalready carry. - Loading.
aria-busy="true"on the field's wrapper while searching (plain);CommandEmpty's existingaria-live="polite"region carries the "Searching…" announcement for the Combobox variant — no separate live region needed there. - Contrast.
--muted-foregroundon--backgroundalready passes WCAG AA in both themes (measured on Input) — the leading icon, spinner, and placeholder all inherit that.
Responsive Behavior
The field itself doesn't collapse — it's already a single control at every width. What changes:
- A plain search field spans its container's width (
w-full) more often on narrow viewports, where a fixed width would otherwise crowd the layout; on wider viewports a caller commonly caps it (max-w-sm, matching Empty States's own text-block cap) so it doesn't stretch edge-to-edge. - The Combobox search's popover follows Popover's own collision handling unchanged — it flips above the trigger and shifts alignment on overflow, exactly as documented there.
- Results presentation (a list, a table, a card grid) follows whatever that container's own responsive contract already is — Search adds no new breakpoint behavior of its own.
Related Components / Patterns
- Input — the plain search field.
- Combobox / Command — search that suggests results.
- Empty States — the no-results case.