Components

Getting started

Components144

More

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

$ npx shadcn@latest add https://hairlineui.com/r/search-input.json

Templates

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.

All 43 templates

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.

Instead of wiring updebounce hookkeyboard shortcut listenerloading spinnerclear button
  1. Debounced search

    Runs after 250ms without typing; Enter runs it immediately.

  2. A real shortcut

    ⌘K / Ctrl K or "/" focuses and selects the field, never while you type elsewhere.

  3. Loading in place

    The magnifier blurs into a spinner and back.

  4. 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

usage.tsx
import { SearchInput } from "@/components/ui/search-input";<SearchInput placeholder="Search customers" loading={isFetching} onSearch={(q) => setQuery(q)} />

Props

PropTypeDescription
onSearch(query: string) => voidAfter `debounce` ms without typing, and at once on Enter.
debouncenumberdefault 250Wait in ms.
loadingbooleandefault falseSpinner in place of the magnifier.
shortcut"mod+k" | "/" | nulldefault "mod+k"Key that focuses the field.
...Input propsInputPropslabel, 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

components/search-input.tsx
"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;
components/field.tsx
"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;
components/input.tsx
"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