Skip to content
Monogem

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 Search glyph 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 spinning Loader2 at the same size and color — the same "spinner replaces the leading icon slot" idiom Button already uses for its own loading state.
  • The field — the real Input primitive, type="search", at its default size only. Its own px-3 base padding is overridden with pl-8 pr-10 so the icon and clear button sit visually inside the bordered box instead of beside it — a className override, 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-sm so its outer corner matches Input's --radius-sm instead 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.

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

search-showcase.tsx
"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

StateLook
EmptySearch icon only, no clear button, placeholder text shown
Has a valueClear button appears at the trailing edge
SearchingLeading icon replaced by a spinning Loader2; aria-busy="true" on the field's wrapper (plain) or zero rendered rows + CommandEmpty showing "Searching…" (Combobox)
Has resultsResults render in the caller's own region (plain) or as <CommandItem>s (Combobox)
No resultsEmpty States no-results composition (plain) or <CommandEmpty>'s "No results" copy (Combobox)
DisabledThe 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 Search icon for a spinning Loader2 (animate-spin, same --scale-4 size and --muted-foreground color) for as long as a search is in flight, and set aria-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 an aria-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 because shouldFilter={false} already hands result-rendering to the caller: while a request is in flight the caller simply renders nothing, CommandEmpty fires on schedule, and once results arrive the caller renders <CommandItem>s and CommandEmpty hides 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 own aria-live="polite" region is the announcement, so no separate Empty States composition is layered on top inside the popover.

Usage Guidance

Two different jobs both start with a search box, and the acceptance criterion for this pattern is keeping them visibly distinct:

Plain search fieldCombobox search
PurposeFilter or query content shown elsewhere on the page — a results list, a filtered table, a grid of cardsPick one item from a set of live-narrowed suggestions
Where results renderThe page's own region, outside the fieldInline, in a popover anchored under the field
What happens on commitNothing is "chosen" — the query just narrows what's already visible, on every keystroke or on submitSelecting a row commits one value, closes the surface, and returns focus to the trigger
Field itselfA real <input type="search">, always visible, always editableThe existing documented Combobox composition — an Input-styled disclosure button opens a <Popover> holding <Command>; the actual typing happens on <CommandInput> inside the surface
Ships asHand-composed markup (below) — no primitiveCommand's already-shipped Combobox pattern, unchanged
Reach for it whenThe result set isn't a bounded list of "things to pick" — a documentation search, a table filter, a product search results pageThe 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.

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 (loading sets aria-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:

GapTokenpxRationale
Icon's inset from the field's left edge--scale-312Reuses Input's own inline padding value ("Input / button padding")
Icon → typed text--scale-14Spacing's own "icon-to-label gap" recipe, reused here for icon-to-text
Field's left padding (pl-8)--scale-832The sum of the above two plus the icon's own --scale-4 width
Field's right padding (pr-10)--scale-1040Clears 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.

TokenWhere used
--scale-3Leading icon's inset from the field's left edge
--scale-4Leading icon / spinner size ("inline with text," Icons)
--scale-1Gap 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-foregroundIcon / spinner color, placeholder text, result-count status text
--input / --background / --foreground / --ringThe field itself — Input's own states, unchanged
--radius-smThe clear button's outer corner, overridden to match the field instead of Button's own --radius-md
--icon-stroke-widthEvery glyph in the composition — Search, the loading spinner, the clear icon, the Combobox trigger's Search icon (Icons)
Button's own tokensClear button — ghost variant, icon size, unchanged
Command's own tokensCombobox 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, or CommandEmpty'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 an aria-label when no visible label fits the layout (a compact toolbar search, for instance). The Combobox variant already inherits this from Command — CommandInput needs a placeholder and an aria-label.
  • Keyboard. The plain field is a native <input> — it takes every native text-editing key for free. Escape is 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 a type="search" field on Escape as native behavior, which this pattern doesn't fight. The clear button is a real <button> — Tab reaches it only while it's rendered, and Enter / Space activate 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 and CommandEmpty already carry.
  • Loading. aria-busy="true" on the field's wrapper while searching (plain); CommandEmpty's existing aria-live="polite" region carries the "Searching…" announcement for the Combobox variant — no separate live region needed there.
  • Contrast. --muted-foreground on --background already 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