Search Input
Original · freesearch box with icon and clear
Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.
npx shadcn@latest add https://webinnoventix.com/r/inp-search.json"use client";
import { useId, useMemo, useRef, useState } from "react";
import type { KeyboardEvent } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
/* ------------------------------------------------------------------ */
/* Types */
/* ------------------------------------------------------------------ */
type Suggestion = {
id: string;
title: string;
kind: string;
hint: string;
};
type AccentKey = "indigo" | "emerald";
type Accent = {
ring: string;
iconOn: string;
optionOn: string;
mark: string;
chip: string;
dot: string;
};
/* ------------------------------------------------------------------ */
/* Icons (inline SVG only) */
/* ------------------------------------------------------------------ */
function SearchGlyph({ className }: { className?: string }) {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
className={className}
aria-hidden="true"
>
<circle cx={11} cy={11} r={7} />
<path d="m20 20-3.2-3.2" />
</svg>
);
}
function ClearGlyph({ className }: { className?: string }) {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
className={className}
aria-hidden="true"
>
<path d="M6 6l12 12M18 6 6 18" />
</svg>
);
}
function ReturnGlyph({ className }: { className?: string }) {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
className={className}
aria-hidden="true"
>
<path d="M9 10 4 15l5 5" />
<path d="M20 4v7a4 4 0 0 1-4 4H4" />
</svg>
);
}
function ClockGlyph({ className }: { className?: string }) {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
className={className}
aria-hidden="true"
>
<circle cx={12} cy={12} r={9} />
<path d="M12 7v5l3.5 2" />
</svg>
);
}
/* ------------------------------------------------------------------ */
/* Static data */
/* ------------------------------------------------------------------ */
const LIBRARY: Suggestion[] = [
{ id: "s1", title: "Invoice INV-2043 — Northwind Traders", kind: "Invoice", hint: "Billing" },
{ id: "s2", title: "Q3 revenue forecast", kind: "Report", hint: "Analytics" },
{ id: "s3", title: "Rotate production API keys", kind: "Runbook", hint: "Security" },
{ id: "s4", title: "Invite Priya Menon to Engineering", kind: "Action", hint: "Team" },
{ id: "s5", title: "Deployment logs — api-gateway", kind: "Logs", hint: "Infra" },
{ id: "s6", title: "Customer: Aster Health Systems", kind: "Account", hint: "CRM" },
{ id: "s7", title: "Refund policy for annual plans", kind: "Doc", hint: "Help" },
{ id: "s8", title: "Webhook: order.completed", kind: "Integration", hint: "Developers" },
];
const RECENTS = ["billing settings", "api keys", "deploy logs", "team invites"];
const PAGES = [
"Account & profile",
"Billing and invoices",
"API keys and tokens",
"Team members and roles",
"Notification preferences",
"Security & two-factor auth",
"Webhooks and events",
"Data export",
"Third-party integrations",
"Danger zone",
];
const ACCENTS: Record<AccentKey, Accent> = {
indigo: {
ring:
"focus-within:border-indigo-400 focus-within:ring-indigo-500/40 dark:focus-within:border-indigo-400/60",
iconOn: "text-indigo-500 dark:text-indigo-400",
optionOn:
"bg-indigo-50 text-indigo-950 dark:bg-indigo-500/15 dark:text-indigo-50",
mark: "bg-indigo-100 text-indigo-900 dark:bg-indigo-400/25 dark:text-indigo-50",
chip:
"border-indigo-200 text-indigo-700 hover:bg-indigo-50 focus-visible:ring-indigo-500/50 dark:border-indigo-400/25 dark:text-indigo-300 dark:hover:bg-indigo-500/10",
dot: "bg-indigo-500 dark:bg-indigo-400",
},
emerald: {
ring:
"focus-within:border-emerald-400 focus-within:ring-emerald-500/40 dark:focus-within:border-emerald-400/60",
iconOn: "text-emerald-600 dark:text-emerald-400",
optionOn:
"bg-emerald-50 text-emerald-950 dark:bg-emerald-500/15 dark:text-emerald-50",
mark: "bg-emerald-100 text-emerald-900 dark:bg-emerald-400/25 dark:text-emerald-50",
chip:
"border-emerald-200 text-emerald-700 hover:bg-emerald-50 focus-visible:ring-emerald-500/50 dark:border-emerald-400/25 dark:text-emerald-300 dark:hover:bg-emerald-500/10",
dot: "bg-emerald-500 dark:bg-emerald-400",
},
};
/* ------------------------------------------------------------------ */
/* Helpers */
/* ------------------------------------------------------------------ */
function Highlight({ text, query, mark }: { text: string; query: string; mark: string }) {
const q = query.trim();
if (!q) return <>{text}</>;
const at = text.toLowerCase().indexOf(q.toLowerCase());
if (at === -1) return <>{text}</>;
return (
<>
{text.slice(0, at)}
<mark className={`rounded-[3px] px-px ${mark}`}>{text.slice(at, at + q.length)}</mark>
{text.slice(at + q.length)}
</>
);
}
/* ------------------------------------------------------------------ */
/* Combobox — search with icon, clear button, keyboard suggestions */
/* ------------------------------------------------------------------ */
function Combobox({
label,
placeholder,
accent,
shape,
recents,
}: {
label: string;
placeholder: string;
accent: AccentKey;
shape: "xl" | "full";
recents?: string[];
}) {
const uid = useId();
const listId = `${uid}-list`;
const inputRef = useRef<HTMLInputElement>(null);
const reduce = useReducedMotion();
const a = ACCENTS[accent];
const [query, setQuery] = useState("");
const [open, setOpen] = useState(false);
const [active, setActive] = useState(-1);
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return [];
return LIBRARY.filter(
(s) => s.title.toLowerCase().includes(q) || s.kind.toLowerCase().includes(q),
).slice(0, 6);
}, [query]);
const showPanel = open && (filtered.length > 0 || query.trim().length > 0);
function commit(value: string) {
setQuery(value);
setOpen(false);
setActive(-1);
inputRef.current?.focus();
}
function clear() {
setQuery("");
setActive(-1);
setOpen(false);
inputRef.current?.focus();
}
function onKeyDown(e: KeyboardEvent<HTMLInputElement>) {
if (e.key === "ArrowDown") {
e.preventDefault();
if (!open) setOpen(true);
setActive((i) => (filtered.length ? Math.min(i + 1, filtered.length - 1) : -1));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setActive((i) => Math.max(i - 1, 0));
} else if (e.key === "Enter") {
if (active >= 0 && filtered[active]) {
e.preventDefault();
commit(filtered[active].title);
}
} else if (e.key === "Escape") {
if (open) {
e.preventDefault();
setOpen(false);
setActive(-1);
} else if (query) {
setQuery("");
}
} else if (e.key === "Home") {
if (open && filtered.length) {
e.preventDefault();
setActive(0);
}
} else if (e.key === "End") {
if (open && filtered.length) {
e.preventDefault();
setActive(filtered.length - 1);
}
}
}
const radius = shape === "full" ? "rounded-full" : "rounded-2xl";
const panelRadius = shape === "full" ? "rounded-3xl" : "rounded-2xl";
const activeId = active >= 0 ? `${uid}-opt-${active}` : undefined;
const busy = query.trim().length > 0;
return (
<div className="relative">
<label htmlFor={`${uid}-input`} className="sr-only">
{label}
</label>
<div
className={`group flex items-center gap-3 border bg-white px-4 py-3 shadow-sm ring-4 ring-transparent transition-colors ${radius} ${a.ring} border-slate-200 dark:border-zinc-700/70 dark:bg-zinc-900/70 ${
shape === "full" ? "pl-5 pr-2" : ""
}`}
>
<span className="relative flex h-5 w-5 shrink-0 items-center justify-center">
<SearchGlyph
className={`h-5 w-5 transition-colors ${
busy ? a.iconOn : "text-slate-400 dark:text-zinc-500"
}`}
/>
{busy ? (
<span
className={`inpsrch-pulse absolute -right-0.5 -top-0.5 h-1.5 w-1.5 rounded-full ${a.dot}`}
/>
) : null}
</span>
<input
ref={inputRef}
id={`${uid}-input`}
type="text"
role="combobox"
aria-expanded={showPanel}
aria-controls={listId}
aria-autocomplete="list"
aria-activedescendant={activeId}
autoComplete="off"
spellCheck={false}
value={query}
placeholder={placeholder}
onChange={(e) => {
setQuery(e.target.value);
setOpen(true);
setActive(-1);
}}
onFocus={() => setOpen(true)}
onBlur={() => setOpen(false)}
onKeyDown={onKeyDown}
className="min-w-0 flex-1 bg-transparent text-[15px] text-slate-900 placeholder:text-slate-400 focus:outline-none dark:text-zinc-50 dark:placeholder:text-zinc-500"
/>
<AnimatePresence initial={false}>
{query ? (
<motion.button
key="clear"
type="button"
onClick={clear}
aria-label="Clear search"
initial={reduce ? false : { opacity: 0, scale: 0.6 }}
animate={{ opacity: 1, scale: 1 }}
exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.6 }}
transition={{ duration: reduce ? 0 : 0.14 }}
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-800 focus:outline-none focus-visible:ring-2 focus-visible:ring-slate-500/60 dark:text-zinc-400 dark:hover:bg-zinc-800 dark:hover:text-zinc-100"
>
<ClearGlyph className="h-4 w-4" />
</motion.button>
) : (
<kbd
key="hint"
className="hidden select-none items-center gap-0.5 rounded-md border border-slate-200 bg-slate-50 px-1.5 py-0.5 font-mono text-[11px] font-medium text-slate-500 sm:flex dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-400"
>
/
</kbd>
)}
</AnimatePresence>
</div>
<AnimatePresence>
{showPanel ? (
<motion.div
initial={reduce ? false : { opacity: 0, y: -6, scale: 0.985 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={reduce ? { opacity: 0 } : { opacity: 0, y: -6, scale: 0.985 }}
transition={{ duration: reduce ? 0 : 0.16, ease: [0.16, 1, 0.3, 1] }}
className={`absolute left-0 right-0 top-[calc(100%+8px)] z-20 overflow-hidden border border-slate-200 bg-white shadow-xl shadow-slate-900/5 dark:border-zinc-700/70 dark:bg-zinc-900 dark:shadow-black/40 ${panelRadius}`}
>
{filtered.length > 0 ? (
<ul id={listId} role="listbox" aria-label={label} className="max-h-72 overflow-y-auto py-1.5">
{filtered.map((s, i) => {
const on = i === active;
return (
<li key={s.id} role="option" id={`${uid}-opt-${i}`} aria-selected={on}>
<button
type="button"
tabIndex={-1}
onMouseDown={(e) => e.preventDefault()}
onMouseEnter={() => setActive(i)}
onClick={() => commit(s.title)}
className={`flex w-full items-center gap-3 px-3 py-2 text-left transition-colors ${
on ? a.optionOn : "text-slate-700 dark:text-zinc-200"
}`}
>
<span
className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-lg border text-slate-500 ${
on
? "border-transparent bg-white/70 dark:bg-white/5"
: "border-slate-200 bg-slate-50 dark:border-zinc-700 dark:bg-zinc-800"
}`}
>
<SearchGlyph className="h-4 w-4" />
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium">
<Highlight text={s.title} query={query} mark={a.mark} />
</span>
<span className="block truncate text-xs text-slate-400 dark:text-zinc-500">
{s.kind} · {s.hint}
</span>
</span>
{on ? (
<ReturnGlyph className="h-4 w-4 shrink-0 opacity-70" />
) : null}
</button>
</li>
);
})}
</ul>
) : (
<div className="px-4 py-6 text-center">
<p className="text-sm font-medium text-slate-700 dark:text-zinc-200">
No matches for “{query.trim()}”
</p>
<p className="mt-1 text-xs text-slate-400 dark:text-zinc-500">
Try a customer name, invoice number, or runbook.
</p>
</div>
)}
{recents && filtered.length > 0 ? (
<div className="flex flex-wrap items-center gap-1.5 border-t border-slate-100 px-3 py-2.5 dark:border-zinc-800">
<ClockGlyph className="h-3.5 w-3.5 text-slate-400 dark:text-zinc-500" />
{recents.map((r) => (
<button
key={r}
type="button"
tabIndex={-1}
onMouseDown={(e) => e.preventDefault()}
onClick={() => commit(r)}
className={`rounded-full border px-2.5 py-0.5 text-xs font-medium transition-colors focus:outline-none focus-visible:ring-2 ${a.chip}`}
>
{r}
</button>
))}
</div>
) : null}
</motion.div>
) : null}
</AnimatePresence>
</div>
);
}
/* ------------------------------------------------------------------ */
/* Inline list filter — search filters a visible list, live count */
/* ------------------------------------------------------------------ */
function ListFilter() {
const uid = useId();
const inputRef = useRef<HTMLInputElement>(null);
const [query, setQuery] = useState("settings");
const results = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return PAGES;
return PAGES.filter((p) => p.toLowerCase().includes(q));
}, [query]);
const mark = "bg-violet-100 text-violet-900 dark:bg-violet-400/25 dark:text-violet-50";
return (
<div>
<label
htmlFor={`${uid}-input`}
className="mb-1.5 block text-xs font-medium text-slate-500 dark:text-zinc-400"
>
Filter settings
</label>
<div className="flex items-center gap-2 border-b-2 border-slate-200 pb-1.5 transition-colors focus-within:border-violet-400 dark:border-zinc-700 dark:focus-within:border-violet-400/70">
<SearchGlyph
className={`h-4 w-4 shrink-0 ${
query ? "text-violet-500 dark:text-violet-400" : "text-slate-400 dark:text-zinc-500"
}`}
/>
<input
ref={inputRef}
id={`${uid}-input`}
type="text"
value={query}
autoComplete="off"
spellCheck={false}
placeholder="Type to filter…"
aria-describedby={`${uid}-count`}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Escape" && query) {
e.preventDefault();
setQuery("");
}
}}
className="min-w-0 flex-1 bg-transparent text-[15px] text-slate-900 placeholder:text-slate-400 focus:outline-none dark:text-zinc-50 dark:placeholder:text-zinc-500"
/>
{query ? (
<button
type="button"
aria-label="Clear filter"
onClick={() => {
setQuery("");
inputRef.current?.focus();
}}
className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-800 focus:outline-none focus-visible:ring-2 focus-visible:ring-violet-500/60 dark:text-zinc-400 dark:hover:bg-zinc-800 dark:hover:text-zinc-100"
>
<ClearGlyph className="h-3.5 w-3.5" />
</button>
) : null}
</div>
<p id={`${uid}-count`} aria-live="polite" className="mt-2 text-xs text-slate-400 dark:text-zinc-500">
{results.length} of {PAGES.length} pages
</p>
<ul className="mt-2 max-h-48 space-y-0.5 overflow-y-auto pr-1">
{results.length > 0 ? (
results.map((p) => (
<li key={p}>
<div className="rounded-lg px-2.5 py-2 text-sm text-slate-700 transition-colors hover:bg-slate-50 dark:text-zinc-200 dark:hover:bg-zinc-800/60">
<Highlight text={p} query={query} mark={mark} />
</div>
</li>
))
) : (
<li className="px-2.5 py-3 text-sm text-slate-400 dark:text-zinc-500">
Nothing matches that filter.
</li>
)}
</ul>
</div>
);
}
/* ------------------------------------------------------------------ */
/* Showcase */
/* ------------------------------------------------------------------ */
export default function InpSearch() {
return (
<section className="relative w-full overflow-hidden bg-gradient-to-b from-slate-50 to-slate-100 px-6 py-20 sm:px-10 dark:from-zinc-950 dark:to-zinc-900">
<style>{`
@keyframes inpsrch-pulse {
0%, 100% { opacity: .4; transform: scale(1); }
50% { opacity: 1; transform: scale(1.5); }
}
@keyframes inpsrch-sheen {
0% { background-position: 0% 50%; }
100% { background-position: 200% 50%; }
}
.inpsrch-pulse { animation: inpsrch-pulse 1.6s ease-in-out infinite; }
.inpsrch-sheen {
background-size: 200% 100%;
animation: inpsrch-sheen 3.2s linear infinite;
}
@media (prefers-reduced-motion: reduce) {
.inpsrch-pulse, .inpsrch-sheen { animation: none !important; }
}
`}</style>
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 opacity-[0.5] [mask-image:radial-gradient(ellipse_at_top,black,transparent_70%)]"
style={{
backgroundImage:
"linear-gradient(to right, rgba(100,116,139,0.12) 1px, transparent 1px), linear-gradient(to bottom, rgba(100,116,139,0.12) 1px, transparent 1px)",
backgroundSize: "44px 44px",
}}
/>
<div className="relative mx-auto max-w-2xl">
<header className="mb-12 text-center">
<span className="inline-flex items-center gap-1.5 rounded-full border border-slate-200 bg-white/70 px-3 py-1 text-xs font-medium text-slate-500 backdrop-blur dark:border-zinc-700 dark:bg-zinc-900/60 dark:text-zinc-400">
<SearchGlyph className="h-3.5 w-3.5" />
inp-search
</span>
<h2 className="mt-4 text-3xl font-semibold tracking-tight text-slate-900 sm:text-4xl dark:text-zinc-50">
Search fields that feel instant
</h2>
<p className="mx-auto mt-3 max-w-md text-[15px] leading-relaxed text-slate-500 dark:text-zinc-400">
Icon, clearable input, keyboard-navigable suggestions and live filtering —
accessible in light and dark.
</p>
<div className="inpsrch-sheen mx-auto mt-6 h-px w-40 bg-gradient-to-r from-transparent via-indigo-400 to-transparent" />
</header>
<div className="space-y-10">
<div>
<p className="mb-2 text-xs font-semibold uppercase tracking-wider text-slate-400 dark:text-zinc-500">
Global search · combobox
</p>
<Combobox
label="Search accounts, invoices and runbooks"
placeholder="Search accounts, invoices, runbooks…"
accent="indigo"
shape="xl"
recents={RECENTS}
/>
<p className="mt-2 pl-1 text-xs text-slate-400 dark:text-zinc-500">
Try typing “invoice” or “api”, then use ↑ ↓ and Enter.
</p>
</div>
<div>
<p className="mb-2 text-xs font-semibold uppercase tracking-wider text-slate-400 dark:text-zinc-500">
Compact · pill shape
</p>
<Combobox
label="Quick find"
placeholder="Quick find…"
accent="emerald"
shape="full"
/>
</div>
<div className="rounded-2xl border border-slate-200 bg-white/70 p-5 backdrop-blur dark:border-zinc-800 dark:bg-zinc-900/50">
<p className="mb-3 text-xs font-semibold uppercase tracking-wider text-slate-400 dark:text-zinc-500">
Inline filter · live results
</p>
<ListFilter />
</div>
</div>
</div>
</section>
);
}Dependencies
Licence
Built by Web Innoventix. Free for personal and commercial use, no attribution required.
Built by Web Innoventix
Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.
Get a free quoteSimilar components
Browse all →
Basic Set Input
Originaldefault, hover, focus, disabled, error text inputs

Floating Label Input
Originallabel that floats up on focus/fill

Filled Input
Originalfilled material-style inputs

Underline Input
Originalminimal underline inputs with animated bar

Search Suggest Input
Originalsearch with a live suggestions dropdown

Password Toggle Input
Originalpassword field with show/hide toggle

Textarea Autosize Input
Originaltextarea that grows with content

Addon Group Input
Originalinput group with leading/trailing addons

Currency Input
Originalcurrency input with formatting

Tags Input
Originaltag input, add and remove chips

OTP Input
Original6-box one-time-code input with paste

Phone Input
Originalphone input with country prefix select

