Inputs
Search Input
A styled search field with an icon, a clear button that appears once there's a value, an optional loading spinner, and a decorative shortcut hint.
Preview in your theme
Loading preview…
- Dependencies
- lucide-react
- Registry dependencies
- utils
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/search-input.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "SearchInput" component (lucide-react
Search/X/Loader2).
Contract
- Export a forwardRef component whose ref points at the inner <input>;
props extend InputHTMLAttributes<HTMLInputElement> minus "type" (the
component always renders type="search").
- Add loading?: boolean (default false) and shortcutHint?: string, plus
onClear?: () => void.
- Fully supports both controlled (value + onChange) and uncontrolled
(defaultValue) usage, same pattern as a PasswordInput: value/defaultValue/
onChange pass straight through to the inner input, unmodified in shape.
Behavior
- A leading Search icon is always shown, decorative (aria-hidden).
- Track whether the field "has a value": in controlled mode read it
straight from value every render (no extra state needed); in
uncontrolled mode mirror it into internal state on every onChange
(seeded once from defaultValue).
- When it has a value, a trailing icon button appears with
aria-label="Clear search". Clicking it: in controlled mode, just call
onClear() — the component never touches value itself, the consumer owns
that state and re-renders with an empty value; in uncontrolled mode,
set the underlying <input>'s DOM value to "" directly, flip the internal
mirror to false, and also call onClear() if provided. Either way, focus
returns to the input afterward.
- Escape, while the field has a value: preventDefault (so it doesn't rely
on the browser's native type="search" escape-clear, which is
inconsistent across engines) and run the exact same clear path as the
button click.
- loading=true replaces whatever is in that trailing slot with a spinning
Loader2 (motion-reduce: no spin, just static) and sets aria-busy on the
input — the clear button and the shortcut hint both yield to it.
- shortcutHint (e.g. "⌘K") renders as a small kbd-styled hint in the
trailing slot, but only when the field is both empty and not loading.
It is pure display — this component does not bind any keyboard
shortcut; wiring metaKey/ctrlKey + "k" to focus the input is the
consumer's job (see Customization levers).
- disabled dims the field and disables the clear button.
- Hide the native type="search" cancel icon
([&::-webkit-search-cancel-button]:hidden) so there's never a second,
browser-drawn clear control fighting the custom one.
Rendering & styling
- Outer field mimics the shadcn Input: flex h-9 items-center gap-2
rounded-md border border-input bg-transparent px-3, focus-within:
border-ring + focus-within:ring-ring/50 so the whole box focuses as one
field; the inner <input> itself is borderless and bg-transparent.
- Icon / spinner / hint all read text-muted-foreground; the clear button
is a ghost icon button (hover:bg-muted hover:text-foreground,
focus-visible ring). The shortcut hint is a <kbd> with
border-input + bg-muted, font-mono, pointer-events-none (display only).
- Semantic tokens only, no hardcoded hex/oklch. Merge the consumer
className via cn() onto the field container.
Customization levers
- Debouncing: this component stays a dumb, uncontrolled-friendly field —
don't debounce inside it. Pair it with the use-debounce-value hook in
the consumer (debounce the value coming out of onChange) before firing
a real request, and flip loading on/off around that request yourself.
- Shortcut binding recipe: shortcutHint only renders the hint. To make
"⌘K" real, add a page-level keydown listener
((e.metaKey || e.ctrlKey) && e.key === "k") that preventDefaults and
calls the forwarded ref's .focus() — completely decoupled from this
component.
- Sizing: h-9 / px-3 / gap-2 are the density knobs on the container; the
icon/button sizes (size-4 / size-5) scale alongside them.
- Icon swap: replace the leading Search icon with a different lucide icon
for a themed variant (e.g. a command-bar look) — none of the clear/
loading/hint logic depends on which icon leads.Concepts
- Clear button on value — the trailing X only exists once there's something to clear, so an empty field never shows a control with nothing to do.
- Controlled vs uncontrolled mirroring — controlled mode reads
hasValuestraight offvalueevery render; uncontrolled mode keeps a small internal mirror updated ononChange, exactly so the clear button knows when to appear without the component secretly owning the value. - Honest clearing — in controlled mode the component never mutates
valueitself, it only callsonClear()and trusts the consumer to update their own state; in uncontrolled mode it clears the DOM node directly since there's no external state to wait for. - Loading swaps the trailing slot — a spinner takes over the exact spot the clear button or shortcut hint would occupy, so the field never grows a second icon while a search is in flight.
- Escape mirrors the button — pressing Escape runs through the identical clear path as clicking the X, instead of relying on browser-specific native
type="search"clearing behavior. - Shortcut hint is decorative only —
shortcutHintrenders a<kbd>label, nothing more; binding the real key combo to focus the field is left to the page, which is where global shortcuts belong anyway.