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.
npx shadcn@latest add @paragon/search-input"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>
);
}