Inputs & Formsv0.1.0
Search Input
Clearing dissolves the query one word at a time.
Installation
CLI
npx shadcn@latest add https://hantui-theta.vercel.app/r/search-input.jsonManual
Install the dependencies
npm install clsx lucide-react motion tailwind-mergeAdd the shared helpers
Components use
cn()fromlib/utils.tsand the motion tokens below. See Installation if you have not set them up yet.lib/motion.tsimport type { Transition, Variants } from "motion/react"; /** Durations in seconds. */ export const duration = { instant: 0.1, quick: 0.18, base: 0.28, slow: 0.45, } as const; /** Cubic-bezier curves. */ export const ease = { /** Entering, UI response. */ out: [0.22, 1, 0.36, 1], /** Moving on screen. */ inOut: [0.65, 0, 0.35, 1], /** Sheets and the mobile menu. */ drawer: [0.32, 0.72, 0, 1], } as const; /** Everyday UI: presses, icon swaps, popovers and modals. Exits run at `exitFactor` of the enter. */ export const ui = { press: { scale: 0.96, duration: 0.12 }, icon: { type: "spring", duration: 0.3, bounce: 0 }, popover: { duration: 0.18, ease: ease.out }, modal: { duration: 0.24, ease: ease.out }, exitFactor: 0.6, } as const; /** Once-per-visit motion on the landing page: hero letters, the reel and the afterimage. */ export const kinetic = { heroLetter: { duration: 0.6, ease: ease.out }, /** Delay per letter, eased so the wave slows toward the end. */ heroStep: 0.035, reelSwap: { type: "spring", duration: 0.5, bounce: 0.1 }, /** Two afterimages: lag in seconds and opacity of each. */ ghost: { lag: [0.04, 0.09], opacity: [0.14, 0.06] }, } as const; /** Spring presets. */ export const spring = { snappy: { type: "spring", stiffness: 500, damping: 30 }, smooth: { type: "spring", stiffness: 260, damping: 26 }, bouncy: { type: "spring", stiffness: 300, damping: 15 }, } as const satisfies Record<string, Transition>; /** Stagger between siblings, in seconds (0.03–0.06). */ export const stagger = { tight: 0.03, base: 0.045, loose: 0.06, } as const; /** Blur + fade + y-offset. Falls back to a plain fade under reduced motion. */ export function enterVariants(reduceMotion?: boolean | null, y = 8): Variants { if (reduceMotion) { return { hidden: { opacity: 0 }, visible: { opacity: 1, transition: { duration: duration.base } }, exit: { opacity: 0, transition: { duration: duration.quick } }, }; } return { hidden: { opacity: 0, y, filter: "blur(4px)" }, visible: { opacity: 1, y: 0, filter: "blur(0px)", transition: { duration: duration.base, ease: ease.out }, }, exit: { opacity: 0, y: -y / 2, filter: "blur(4px)", transition: { duration: duration.quick, ease: ease.out }, }, }; } /** Scale 0.96 → 1 for popovers, menus and dialogs. */ export function overlayVariants(reduceMotion?: boolean | null): Variants { if (reduceMotion) { return { hidden: { opacity: 0 }, visible: { opacity: 1, transition: { duration: duration.quick } }, exit: { opacity: 0, transition: { duration: duration.instant } }, }; } return { hidden: { opacity: 0, scale: 0.96, filter: "blur(2px)" }, visible: { opacity: 1, scale: 1, filter: "blur(0px)", transition: { duration: duration.quick, ease: ease.out }, }, exit: { opacity: 0, scale: 0.96, filter: "blur(2px)", transition: { duration: duration.instant + 0.02, ease: ease.out }, }, }; } /** Parent variants that stagger their children in. */ export function staggerVariants(gap: number = stagger.base, delayChildren = 0): Variants { return { hidden: {}, visible: { transition: { staggerChildren: gap, delayChildren } }, exit: { transition: { staggerChildren: gap / 2, staggerDirection: -1 } }, }; }Copy the source
components/ui/search-input/search-input.tsx"use client"; import { useRef, useState, type ComponentProps } from "react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { Search, X } from "lucide-react"; import { cn } from "@/lib/utils"; import { duration, ease } from "@/lib/motion"; export interface SearchInputProps extends Omit<ComponentProps<"input">, "value" | "defaultValue" | "onChange" | "type" | "size"> { /** Controlled query. */ value?: string; /** Initial query when uncontrolled. @default "" */ defaultValue?: string; /** Called with the query on every change. */ onValueChange?: (value: string) => void; /** Called after the query is cleared with the button or Escape. */ onClear?: () => void; /** Replace the search icon with a spinner. @default false */ loading?: boolean; /** Field size. @default "md" */ size?: "sm" | "md" | "lg"; /** Classes for the outer wrapper. */ className?: string; } const sizes = { sm: { field: "h-8 text-[13px]", input: "pr-8 pl-8", icon: "left-2.5 size-3.5", text: "left-8", clear: "right-1 size-6" }, md: { field: "h-10 text-sm", input: "pr-10 pl-9", icon: "left-3 size-4", text: "left-9", clear: "right-1.5 size-7" }, lg: { field: "h-12 text-base", input: "pr-12 pl-11", icon: "left-3.5 size-5", text: "left-11", clear: "right-2 size-8" }, } as const; interface Ghost { id: number; tokens: string[]; offset: number; } export function SearchInput({ value, defaultValue = "", onValueChange, onClear, loading = false, size = "md", className, placeholder = "Search…", disabled, onKeyDown, ...props }: SearchInputProps) { const [internal, setInternal] = useState(defaultValue); const query = value ?? internal; const [ghost, setGhost] = useState<Ghost | null>(null); const inputRef = useRef<HTMLInputElement>(null); const reduceMotion = useReducedMotion(); const s = sizes[size]; function update(next: string) { if (value === undefined) setInternal(next); onValueChange?.(next); } function clear() { if (!query) return; const input = inputRef.current; if (!reduceMotion) { // Snapshot the words so they can dissolve while the real field is already empty. setGhost({ id: Date.now(), tokens: query.split(/(\s+)/).filter(Boolean), offset: input ? -input.scrollLeft : 0 }); } update(""); onClear?.(); input?.focus(); } const icon = { initial: { opacity: 0, scale: 0.6 }, animate: { opacity: 1, scale: 1 }, exit: { opacity: 0, scale: 0.6 } }; return ( <div className={cn("relative flex w-full items-center text-foreground", s.field, className)}> <span aria-hidden="true" className={cn("pointer-events-none absolute top-1/2 z-10 -translate-y-1/2 text-muted-foreground", s.icon)} > <AnimatePresence mode="popLayout" initial={false}> {loading ? ( <motion.svg key="spinner" viewBox="0 0 24 24" fill="none" className="size-full" variants={icon} initial="initial" animate="animate" exit="exit" transition={{ duration: duration.quick }} > <g className="origin-center animate-spin [transform-box:fill-box] motion-reduce:animate-pulse"> <circle cx="12" cy="12" r="8.5" stroke="currentColor" strokeOpacity="0.25" strokeWidth="2.5" /> <path d="M20.5 12A8.5 8.5 0 0 0 12 3.5" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" /> </g> </motion.svg> ) : ( <motion.span key="search" className="block size-full" variants={icon} initial="initial" animate="animate" exit="exit" transition={{ duration: duration.quick }} > <Search className="size-full" strokeWidth={2} /> </motion.span> )} </AnimatePresence> </span> <input ref={inputRef} type="search" enterKeyHint="search" autoComplete="off" spellCheck={false} value={query} disabled={disabled} placeholder={placeholder} onChange={(event) => update(event.target.value)} onKeyDown={(event) => { onKeyDown?.(event); if (event.key === "Escape" && query && !event.defaultPrevented) { event.preventDefault(); clear(); } }} className={cn( "h-full w-full rounded-md border border-input bg-background shadow-xs outline-none", "transition-[border-color,box-shadow] duration-150 placeholder:text-muted-foreground", "focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/20 disabled:cursor-not-allowed disabled:opacity-50", "[&::-webkit-search-cancel-button]:appearance-none [&::-webkit-search-decoration]:appearance-none", ghost && "placeholder:text-transparent", s.input, )} {...props} /> {/* The cleared words, dissolving one by one from the end. */} {ghost ? ( <span key={ghost.id} aria-hidden="true" className={cn("pointer-events-none absolute inset-y-0 flex items-center whitespace-pre", s.text)} style={{ transform: `translateX(${ghost.offset}px)` }} > {ghost.tokens.map((token, i) => ( <motion.span key={i} className="inline-block" initial={{ opacity: 1, y: 0, filter: "blur(0px)" }} animate={{ opacity: 0, y: -6, filter: "blur(6px)" }} transition={{ duration: duration.slow - 0.1, ease: ease.out, delay: (ghost.tokens.length - 1 - i) * 0.05 }} onAnimationComplete={i === 0 ? () => setGhost((g) => (g?.id === ghost.id ? null : g)) : undefined} > {token} </motion.span> ))} </span> ) : null} <AnimatePresence initial={false}> {query && !disabled ? ( <motion.button type="button" aria-label="Clear search" onClick={clear} initial={reduceMotion ? { opacity: 0 } : { opacity: 0, scale: 0.5, filter: "blur(2px)" }} animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }} exit={reduceMotion ? { opacity: 0 } : { opacity: 0, scale: 0.5, filter: "blur(2px)" }} transition={{ duration: duration.quick, ease: ease.out }} className={cn( "absolute top-1/2 inline-flex -translate-y-1/2 cursor-pointer items-center justify-center rounded-md text-muted-foreground outline-none", "transition-colors after:absolute after:-inset-1.5 hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring", s.clear, )} > <X className="size-3.5" aria-hidden="true" /> </motion.button> ) : null} </AnimatePresence> </div> ); }
Usage
import { SearchInput } from "@/components/ui/search-input/search-input";
export function DocsSearch() {
const [query, setQuery] = useState("");
return <SearchInput aria-label="Search docs" value={query} onValueChange={setQuery} />;
}API reference
| Prop | Type | Default | Description |
|---|---|---|---|
value | string | – | Controlled query. |
defaultValue | string | "" | Initial query when uncontrolled. |
onValueChange | (value: string) => void | – | Called with the query on every change. |
onClear | () => void | – | Called after the query is cleared with the button or Escape. |
loading | boolean | false | Replace the search icon with a spinner. |
size | "sm" | "md" | "lg" | "md" | Field size. |
className | string | – | Classes for the outer wrapper. |
Also accepts every native <input> attribute, including ref and className.
- Pass loading while a request is in flight; the search icon morphs into a spinner in the same spot.
- The browser's own clear button is hidden so there is only one.
Accessibility
- Uses a native type="search" input, which screen readers announce as a search field. Give it a label or aria-label.
- The clear button is labelled "Clear search" and returns focus to the field. Escape clears too.
- The field empties immediately; the dissolving words are an aria-hidden copy, so assistive tech never reads stale text.
- With reduced motion enabled, clearing is instant and the spinner pulses instead of rotating.