Search input
The field at the top of every list: ⌘K focuses it from anywhere, search runs after a pause, the magnifier blurs into a spinner while it loads.
Used forList and table headersDocs and help centresApp top barsPickers and sidebars
Install with the shadcn CLI
$ npxnpx shadcn@latest add https://hairlineui.com/r/search-input.jsonTemplates
Need the whole page?
43 Next.js landing pages, each designed from scratch. Try any of them live; one is $39, all of them $99, and 5 are free.
Where it’s used
List and table headers
Search above customers, invoices or files.
Docs and help centres
A "/" shortcut like GitHub and MDN.
App top bars
A ⌘K field that focuses from anywhere.
Pickers and sidebars
Filter a long list as you type.
What it handles for you
Each search box ends up with its own timer, its own keyboard listener and a spinner bolted on beside it. This one has them built in and keeps them out of each other's way.
- 01
Debounced search
Runs after 250ms without typing; Enter runs it immediately.
- 02
A real shortcut
⌘K / Ctrl K or "/" focuses and selects the field, never while you type elsewhere.
- 03
Loading in place
The magnifier blurs into a spinner and back.
- 04
Clear and Escape
The clear button folds in with the first letter; Escape leaves the field.
Overview
Search fields usually wire their own debounce, shortcut and spinner, and the shortcut hint overlaps the text. This one runs onSearch after a pause (Enter runs it at once), focuses on ⌘K or "/" from anywhere on the page, steps its key hint aside as you type, and turns the magnifier into a spinner while results load.
States
- rest
- Magnifier, placeholder and the ⌘K hint.
- typing
- The hint folds away (320ms) and the clear button folds in.
- loading
- The magnifier blurs into a spinner (scale 0.6, 3px).
- focused by shortcut
- The field takes focus and selects its text.
Usage
import { SearchInput } from "@/components/ui/search-input";<SearchInput placeholder="Search customers" loading={isFetching} onSearch={(q) => setQuery(q)} />Props
| Prop | Type | Description |
|---|---|---|
| onSearch | (query: string) => void | After `debounce` ms without typing, and at once on Enter. |
| debounce | numberdefault 250 | Wait in ms. |
| loading | booleandefault false | Spinner in place of the magnifier. |
| shortcut | "mod+k" | "/" | nulldefault "mod+k" | Key that focuses the field. |
| ...Input props | InputProps | label, size, value, onValueChange and every native prop. |
Accessibility and motion
- Shares field.tsx with every text control: one surface, one focus ring, one way of showing hints and errors.
- role="searchbox"; the shortcut hint is aria-hidden (the key is a convenience, not a name).
- The browser's own cancel button is hidden in favour of the folding one.
Requirements
React 19, Tailwind CSS v4 and shadcn/ui theme variables (any style, light and dark). Icons from lucide-react.
The CLI also installs ui-interaction-motion.
Source
"use client";import * as React from "react";import { Input, type InputProps } from "./input";import { EASE_OUT, interactionIcon, useInteractionReducedMotion } from "./ui-interaction-motion";/* ───────────────────────────────────────────────────────── * SEARCH INPUT: the field at the top of every list * * shortcut a ⌘K (or "/") hint sits at the end; the key * focuses the field from anywhere on the page, and * the hint steps aside once you type * search onSearch runs after a pause in typing (debounced); * Enter runs it at once, Escape clears and leaves * loading the magnifier blurs into a spinner and back * clear the clear button folds in with the first letter * ───────────────────────────────────────────────────────── */export interface SearchInputProps extends Omit<InputProps, "leading" | "type" | "clearable"> { /** Called with the query after `debounce` ms without typing, and at once on Enter. */ onSearch?: (query: string) => void; debounce?: number; /** Shows a spinner in place of the magnifier. */ loading?: boolean; /** "mod+k" (⌘K / Ctrl K), "/" or null for no shortcut. */ shortcut?: "mod+k" | "/" | null;}function Magnifier() { return ( <svg viewBox="0 0 16 16" fill="none" className="size-3.5"> <circle cx="7" cy="7" r="4.6" stroke="currentColor" strokeWidth="1.5" /> <path d="M10.4 10.4 13.5 13.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" /> </svg> );}const subscribeNoop = () => () => {};const isMac = () => /Mac|iPhone|iPad/.test(navigator.platform);export function SearchInput({ onSearch, debounce = 250, loading = false, shortcut = "mod+k", placeholder = "Search", value, defaultValue, onValueChange, onKeyDown, onFocus, onBlur, size = "md", ref, ...props}: SearchInputProps) { const reduced = useInteractionReducedMotion(); const inner = React.useRef<HTMLInputElement>(null); const [text, setText] = React.useState(String(defaultValue ?? "")); const [focused, setFocused] = React.useState(false); const current = value !== undefined ? String(value) : text; const mac = React.useSyncExternalStore(subscribeNoop, isMac, () => true); const searchRef = React.useRef(onSearch); React.useEffect(() => { searchRef.current = onSearch; }); // Debounced search: each keystroke restarts the wait. const first = React.useRef(true); React.useEffect(() => { if (first.current) { first.current = false; return; } const timer = window.setTimeout(() => searchRef.current?.(current), debounce); return () => window.clearTimeout(timer); }, [current, debounce]); // The shortcut focuses the field from anywhere, unless you're typing somewhere else. React.useEffect(() => { if (!shortcut) return; const onKey = (e: KeyboardEvent) => { const target = e.target as HTMLElement; const typing = target.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName); const hit = shortcut === "/" ? e.key === "/" && !typing : e.key.toLowerCase() === "k" && (e.metaKey || e.ctrlKey); if (!hit) return; e.preventDefault(); inner.current?.focus(); inner.current?.select(); }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [shortcut]); const showKey = !!shortcut && !focused && !current; const keyLabel = shortcut === "/" ? "/" : mac ? "⌘K" : "Ctrl K"; return ( <Input {...props} ref={(el) => { inner.current = el; if (typeof ref === "function") ref(el); else if (ref) (ref as React.RefObject<HTMLInputElement | null>).current = el; }} type="search" role="searchbox" size={size} clearable placeholder={placeholder} value={value} defaultValue={defaultValue} inputClassName="[&::-webkit-search-cancel-button]:appearance-none" onValueChange={(v) => { if (value === undefined) setText(v); onValueChange?.(v); }} onFocus={(e) => { setFocused(true); onFocus?.(e); }} onBlur={(e) => { setFocused(false); onBlur?.(e); }} onKeyDown={(e) => { onKeyDown?.(e); if (e.key === "Enter") onSearch?.(current); if (e.key === "Escape" && current) { e.preventDefault(); e.currentTarget.blur(); } }} leading={ <span className="relative size-3.5" aria-hidden="true"> <span className="absolute inset-0 flex items-center justify-center" style={interactionIcon(!loading, reduced)}> <Magnifier /> </span> <span className="absolute inset-0 flex items-center justify-center" style={interactionIcon(loading, reduced)}> <span className={`size-3 rounded-full border-[1.5px] border-current border-t-transparent ${loading ? "animate-spin" : ""}`} /> </span> </span> } trailing={ shortcut ? ( <span className="grid" style={{ gridTemplateColumns: showKey ? "1fr" : "0fr", transition: reduced ? "none" : `grid-template-columns 320ms ${EASE_OUT}` }} > <span className="min-w-0 overflow-hidden"> <kbd aria-hidden="true" className={`mr-1.5 block whitespace-nowrap rounded-full px-1.5 font-mono text-[10.5px] leading-[18px] text-muted-foreground shadow-[inset_0_0_0_1px_var(--border)]`} style={{ opacity: showKey ? 1 : 0, filter: showKey ? "none" : "blur(3px)", transition: reduced ? "none" : `opacity 200ms ${EASE_OUT}, filter 200ms ${EASE_OUT}` }} > {keyLabel} </kbd> </span> </span> ) : undefined } /> );}export default SearchInput;"use client";import * as React from "react";import { EASE_OUT, useInteractionReducedMotion } from "./ui-interaction-motion";/* ───────────────────────────────────────────────────────── * FIELD: the column every text control sits in * * label above, with an optional aside on the right (a count) * hint under the control while all is well * error takes the hint's place: the hint folds away, the * error rises in out of a blur, the control shakes once * * Shared by input, textarea, password, search and select so the * whole family has one surface, one focus ring and one error. * ───────────────────────────────────────────────────────── */export interface FieldProps { id: string; label?: React.ReactNode; /** Right side of the label row, e.g. a character count. */ aside?: React.ReactNode; hint?: React.ReactNode; error?: React.ReactNode; required?: boolean; className?: string; children: React.ReactNode;}export type FieldSize = "sm" | "md";/** The surface of a text control: a hairline that darkens on hover and turns to the ring on focus. */export function fieldSurface({ invalid, disabled, round = true }: { invalid?: boolean; disabled?: boolean; round?: boolean }) { const ring = invalid ? "shadow-[inset_0_0_0_1px_color-mix(in_oklab,var(--color-red-500)_55%,transparent)] focus-within:shadow-[inset_0_0_0_1px_var(--color-red-500),0_0_0_3px_color-mix(in_oklab,var(--color-red-500)_14%,transparent)]" : "shadow-[inset_0_0_0_1px_var(--border)] [&:not(:focus-within)]:hover:shadow-[inset_0_0_0_1px_color-mix(in_oklab,var(--foreground)_24%,var(--border))] focus-within:shadow-[inset_0_0_0_1px_var(--ring),0_0_0_3px_color-mix(in_oklab,var(--ring)_16%,transparent)]"; return `relative flex w-full items-center bg-background text-foreground transition-[box-shadow,background-color,opacity] duration-200 ease-[cubic-bezier(0.16,1,0.3,1)] ${round ? "rounded-full" : "rounded-[14px]"} ${ring} ${ disabled ? "pointer-events-none opacity-50" : "" }`;}/** ids for the control's label, hint and error. */export function useFieldIds(id?: string) { const auto = React.useId(); const base = id ?? auto; return { id: base, hint: `${base}-hint`, error: `${base}-error` };}/** One short shake whenever `on` turns true (an error arrives). */export function useShake<T extends HTMLElement>(on: boolean) { const ref = React.useRef<T>(null); const reduced = useInteractionReducedMotion(); React.useEffect(() => { if (!on || reduced) return; ref.current?.animate( [{ transform: "none" }, { transform: "translateX(-4px)" }, { transform: "translateX(4px)" }, { transform: "translateX(-2px)" }, { transform: "none" }], { duration: 380, easing: "ease-out" }, ); }, [on, reduced]); return ref;}/** Sets a native input's value so React's onChange still fires (clear buttons, pickers). */export function setNativeValue(el: HTMLInputElement | HTMLTextAreaElement, value: string) { const proto = el instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype; Object.getOwnPropertyDescriptor(proto, "value")?.set?.call(el, value); el.dispatchEvent(new Event("input", { bubbles: true }));}/** A line under the control that folds open and closed; keeps its last words while closing. */function Line({ show, children, id, tone, reduced }: { show: boolean; children: React.ReactNode; id: string; tone: string; reduced: boolean }) { const [kept, setKept] = React.useState(children); if (show && children !== kept) setKept(children); return ( <div className="grid" style={{ gridTemplateRows: show ? "1fr" : "0fr", transition: reduced ? "none" : `grid-template-rows 320ms ${EASE_OUT}` }} > <div className="min-h-0 overflow-hidden"> <p id={id} className={`pt-1.5 pl-3 text-[12.5px] leading-snug ${tone}`} style={{ opacity: show ? 1 : 0, filter: show ? "blur(0px)" : "blur(4px)", transform: show ? "none" : "translateY(-4px)", transition: reduced ? "none" : show ? `opacity 280ms ${EASE_OUT} 60ms, filter 280ms ${EASE_OUT} 60ms, transform 380ms ${EASE_OUT}` : `opacity 140ms ${EASE_OUT}, filter 140ms ${EASE_OUT}, transform 200ms ${EASE_OUT}`, }} > {show ? children : kept} </p> </div> </div> );}export function Field({ id, label, aside, hint, error, required, className = "", children }: FieldProps) { const reduced = useInteractionReducedMotion(); const ids = { hint: `${id}-hint`, error: `${id}-error` }; return ( <div className={`flex w-full flex-col ${className}`}> {(label || aside) && ( <div className="flex items-baseline justify-between gap-3 px-3 pb-1.5"> {label && ( <label htmlFor={id} className="text-[13px] font-medium text-foreground"> {label} {required && ( <span aria-hidden="true" className="ml-0.5 text-muted-foreground"> * </span> )} </label> )} {aside && <span className="text-[12px] tabular-nums text-muted-foreground">{aside}</span>} </div> )} {children} <Line show={!error && !!hint} id={ids.hint} tone="text-muted-foreground" reduced={reduced}> {hint} </Line> <Line show={!!error} id={ids.error} tone="text-red-600 dark:text-red-400" reduced={reduced}> {error} </Line> </div> );}export default Field;"use client";import * as React from "react";import { Field, fieldSurface, setNativeValue, useFieldIds, useShake, type FieldSize } from "./field";import { EASE_OUT, INTERACTION_FOCUS, interactionIcon, useInteractionReducedMotion } from "./ui-interaction-motion";/* ───────────────────────────────────────────────────────── * INPUT: a one-line field * * rest a hairline pill; hover darkens the line * focus the line turns to the ring with a 3px halo * typing a clear button folds in once there is text and * folds away when it's gone (it hands focus back) * error the line turns red, the hint gives way to the * error and the field shakes once * ───────────────────────────────────────────────────────── */export interface InputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "size"> { label?: React.ReactNode; hint?: React.ReactNode; /** Shows red and replaces the hint. */ error?: React.ReactNode; /** An icon or text before the value. */ leading?: React.ReactNode; /** An icon, text or button after the value. */ trailing?: React.ReactNode; /** A button that empties the field once it has text. */ clearable?: boolean; /** 28px or 36px tall. */ size?: FieldSize; /** Called with the new text on every change. */ onValueChange?: (value: string) => void; ref?: React.Ref<HTMLInputElement>; /** Class for the outer column. */ className?: string; /** Class for the input element itself. */ inputClassName?: string;}function ClearIcon() { return ( <svg viewBox="0 0 16 16" fill="none" className="size-3.5"> <path d="M4.5 4.5l7 7M11.5 4.5l-7 7" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" /> </svg> );}export function Input({ label, hint, error, leading, trailing, clearable, size = "md", onValueChange, ref, id, className = "", inputClassName = "", value, defaultValue, onChange, disabled, required, ...props}: InputProps) { const reduced = useInteractionReducedMotion(); const ids = useFieldIds(id); const inner = React.useRef<HTMLInputElement>(null); const [text, setText] = React.useState(String(defaultValue ?? "")); const current = value !== undefined ? String(value) : text; const shakeRef = useShake<HTMLDivElement>(!!error); const sm = size === "sm"; const showClear = !!clearable && current.length > 0 && !disabled; const setRefs = (el: HTMLInputElement | null) => { inner.current = el; if (typeof ref === "function") ref(el); else if (ref) (ref as React.RefObject<HTMLInputElement | null>).current = el; }; const describedBy = [hint && !error ? ids.hint : null, error ? ids.error : null].filter(Boolean).join(" ") || undefined; return ( <Field id={ids.id} label={label} hint={hint} error={error} required={required} className={className}> <div ref={shakeRef} className={`${fieldSurface({ invalid: !!error, disabled })} ${sm ? "h-7 text-[12.5px]" : "h-9 text-[13.5px]"}`}> {leading && <span className={`flex shrink-0 items-center text-muted-foreground ${sm ? "pl-2.5" : "pl-3.5"} [&_svg]:size-3.5`}>{leading}</span>} <input ref={setRefs} id={ids.id} value={value} defaultValue={defaultValue} disabled={disabled} required={required} aria-invalid={error ? true : undefined} aria-describedby={describedBy} onChange={(e) => { if (value === undefined) setText(e.target.value); onChange?.(e); onValueChange?.(e.target.value); }} className={`h-full min-w-0 flex-1 bg-transparent outline-none placeholder:text-muted-foreground/70 ${leading ? "pl-2" : sm ? "pl-3" : "pl-4"} ${trailing || clearable ? "pr-1" : sm ? "pr-3" : "pr-4"} ${inputClassName}`} {...props} /> {clearable && ( // The clear button folds its own width open, so the text never jumps. <span className="grid shrink-0" style={{ gridTemplateColumns: showClear ? "1fr" : "0fr", transition: reduced ? "none" : `grid-template-columns 320ms ${EASE_OUT}` }} > <span className="min-w-0 overflow-hidden"> <button type="button" tabIndex={showClear ? 0 : -1} aria-hidden={!showClear} aria-label="Clear" onClick={() => { if (!inner.current) return; setNativeValue(inner.current, ""); inner.current.focus(); }} className={`mr-1 flex items-center justify-center rounded-full text-muted-foreground cursor-pointer hover:bg-accent hover:text-foreground ${sm ? "size-5" : "size-7"} ${INTERACTION_FOCUS}`} style={interactionIcon(showClear, reduced)} > <ClearIcon /> </button> </span> </span> )} {trailing && <span className={`flex shrink-0 items-center text-muted-foreground ${sm ? "pr-1" : "pr-1.5"} [&_svg]:size-3.5`}>{trailing}</span>} </div> </Field> );}export default Input;Registry item: /r/search-input.json


