Inputs & Forms

Search Input

A search field with leading icon, a keyboard hint that fades out on focus, an icon-swap clear button that appears with a value, and Escape-to-clear.

Install

npx shadcn@latest add @paragon/search-input

search-input.tsx

"use client";

import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Search, X } from "lucide-react";
import { cn } from "@/lib/utils";

export interface SearchInputProps
  extends Omit<React.ComponentProps<"input">, "type"> {
  /** Keyboard hint shown while idle and empty. Pass null to hide. */
  shortcut?: string | null;
  /** Called after the field is cleared (Escape or the clear button). */
  onClear?: () => void;
  /** Class for the wrapping element. */
  containerClassName?: string;
}

/**
 * Search field with a leading icon, a keyboard hint that fades out on focus,
 * and a clear button that swaps in via the house icon-swap recipe whenever
 * there's a value. Escape clears. Clearing dispatches a native input event,
 * so controlled and uncontrolled usage both stay in sync.
 */
export function SearchInput({
  shortcut = "⌘K",
  onClear,
  containerClassName,
  className,
  value,
  defaultValue,
  onChange,
  onKeyDown,
  ...props
}: SearchInputProps) {
  const inputRef = React.useRef<HTMLInputElement>(null);
  const reducedMotion = useReducedMotion();
  const [internal, setInternal] = React.useState(String(defaultValue ?? ""));
  const current = value !== undefined ? String(value) : internal;
  const hasValue = current.length > 0;

  const clear = () => {
    const el = inputRef.current;
    if (!el) return;
    // Set through the native setter so React sees a real input event —
    // controlled parents receive onChange, uncontrolled state stays synced.
    const setter = Object.getOwnPropertyDescriptor(
      window.HTMLInputElement.prototype,
      "value",
    )?.set;
    setter?.call(el, "");
    el.dispatchEvent(new Event("input", { bubbles: true }));
    el.focus();
    onClear?.();
  };

  return (
    <div className={cn("relative w-full", containerClassName)}>
      <Search
        aria-hidden
        className="pointer-events-none absolute top-1/2 left-2.5 size-4 -translate-y-1/2 text-muted-foreground"
      />
      <input
        ref={inputRef}
        type="search"
        value={value}
        defaultValue={value === undefined ? defaultValue : undefined}
        onChange={(event) => {
          if (value === undefined) setInternal(event.target.value);
          onChange?.(event);
        }}
        onKeyDown={(event) => {
          if (event.key === "Escape" && hasValue) {
            event.preventDefault();
            clear();
          }
          onKeyDown?.(event);
        }}
        className={cn(
          "peer h-9 w-full min-w-0 rounded-lg border border-input bg-transparent pr-9 pl-8 text-sm text-foreground",
          "transition-[border-color,box-shadow] duration-150 ease-out",
          "placeholder:text-muted-foreground",
          "outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/25",
          "disabled:cursor-not-allowed disabled:opacity-50",
          "[&::-webkit-search-cancel-button]:appearance-none",
          className,
        )}
        {...props}
      />
      {shortcut && (
        <kbd
          aria-hidden
          className={cn(
            "pointer-events-none absolute top-1/2 right-2 -translate-y-1/2 rounded border bg-secondary px-1.5 py-px font-sans text-[11px] text-muted-foreground",
            "transition-opacity duration-150 ease-out peer-focus:opacity-0",
            hasValue && "opacity-0",
          )}
        >
          {shortcut}
        </kbd>
      )}
      <span className="pointer-events-none absolute inset-y-0 right-1.5 flex items-center">
        <AnimatePresence mode="popLayout" initial={false}>
          {hasValue && (
            <motion.button
              key="clear"
              type="button"
              aria-label="Clear search"
              onClick={clear}
              initial={
                reducedMotion
                  ? { opacity: 0 }
                  : { opacity: 0, scale: 0.25, filter: "blur(4px)" }
              }
              animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
              exit={
                reducedMotion
                  ? { opacity: 0 }
                  : { opacity: 0, scale: 0.25, filter: "blur(4px)" }
              }
              transition={{ type: "spring", duration: 0.3, bounce: 0 }}
              className="pointer-events-auto relative flex size-6 items-center justify-center rounded-md text-muted-foreground transition-colors duration-150 hover:text-foreground after:absolute after:top-1/2 after:left-1/2 after:size-10 after:-translate-x-1/2 after:-translate-y-1/2"
            >
              <X aria-hidden className="size-3.5" />
            </motion.button>
          )}
        </AnimatePresence>
      </span>
    </div>
  );
}