Search Suggest Input
Original · freesearch with a live suggestions dropdown
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-suggest.json"use client";
import {
type KeyboardEvent as ReactKeyboardEvent,
type ReactNode,
useEffect,
useId,
useMemo,
useRef,
useState,
} from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
/* ------------------------------------------------------------------ */
/* Types */
/* ------------------------------------------------------------------ */
type IconKind =
| "book"
| "user"
| "card"
| "link"
| "code"
| "person"
| "bolt"
| "pin";
type Accent = "indigo" | "emerald";
interface Item {
id: string;
label: string;
category: string;
scope: string;
hint: string;
kind: IconKind;
}
interface AccentTokens {
ring: string;
focusWithin: string;
caret: string;
optActive: string;
optBar: string;
iconTint: string;
mark: string;
scopeActive: string;
pill: string;
spin: string;
chipHover: string;
}
/* ------------------------------------------------------------------ */
/* Static accent maps (kept literal so Tailwind never purges them) */
/* ------------------------------------------------------------------ */
const ACCENTS: Record<Accent, AccentTokens> = {
indigo: {
ring: "focus-visible:ring-indigo-500/60 dark:focus-visible:ring-indigo-400/60",
focusWithin:
"focus-within:border-indigo-400 focus-within:ring-4 focus-within:ring-indigo-500/15 dark:focus-within:border-indigo-500/70 dark:focus-within:ring-indigo-400/15",
caret: "caret-indigo-600 dark:caret-indigo-400",
optActive: "bg-indigo-50 dark:bg-indigo-500/10",
optBar: "bg-indigo-500 dark:bg-indigo-400",
iconTint: "text-indigo-600 dark:text-indigo-400",
mark: "bg-indigo-100 text-indigo-900 dark:bg-indigo-400/25 dark:text-indigo-50",
scopeActive: "bg-indigo-600 text-white shadow-sm dark:bg-indigo-500",
pill: "border-indigo-200 bg-indigo-50 text-indigo-700 dark:border-indigo-400/30 dark:bg-indigo-400/10 dark:text-indigo-200",
spin: "text-indigo-500 dark:text-indigo-400",
chipHover:
"hover:border-indigo-300 hover:text-indigo-700 dark:hover:border-indigo-400/40 dark:hover:text-indigo-200",
},
emerald: {
ring: "focus-visible:ring-emerald-500/60 dark:focus-visible:ring-emerald-400/60",
focusWithin:
"focus-within:border-emerald-400 focus-within:ring-4 focus-within:ring-emerald-500/15 dark:focus-within:border-emerald-500/70 dark:focus-within:ring-emerald-400/15",
caret: "caret-emerald-600 dark:caret-emerald-400",
optActive: "bg-emerald-50 dark:bg-emerald-500/10",
optBar: "bg-emerald-500 dark:bg-emerald-400",
iconTint: "text-emerald-600 dark:text-emerald-400",
mark: "bg-emerald-100 text-emerald-900 dark:bg-emerald-400/25 dark:text-emerald-50",
scopeActive: "bg-emerald-600 text-white shadow-sm dark:bg-emerald-500",
pill: "border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-400/30 dark:bg-emerald-400/10 dark:text-emerald-200",
spin: "text-emerald-500 dark:text-emerald-400",
chipHover:
"hover:border-emerald-300 hover:text-emerald-700 dark:hover:border-emerald-400/40 dark:hover:text-emerald-200",
},
};
/* ------------------------------------------------------------------ */
/* Data */
/* ------------------------------------------------------------------ */
const WORKSPACE_ITEMS: Item[] = [
{ id: "d-start", label: "Getting started guide", category: "Guides", scope: "Docs", hint: "docs/start", kind: "book" },
{ id: "d-invite", label: "Invite teammates", category: "Guides", scope: "Docs", hint: "docs/team", kind: "book" },
{ id: "d-shortcuts", label: "Keyboard shortcuts", category: "Guides", scope: "Docs", hint: "docs/shortcuts", kind: "book" },
{ id: "d-pass", label: "Reset your password", category: "Account", scope: "Docs", hint: "docs/account", kind: "user" },
{ id: "d-2fa", label: "Two-factor authentication", category: "Account", scope: "Docs", hint: "docs/security", kind: "user" },
{ id: "d-bill", label: "Update billing details", category: "Billing", scope: "Docs", hint: "docs/billing", kind: "card" },
{ id: "d-inv", label: "Download past invoices", category: "Billing", scope: "Docs", hint: "docs/billing", kind: "card" },
{ id: "d-slack", label: "Connect Slack", category: "Integrations", scope: "Docs", hint: "docs/integrations", kind: "link" },
{ id: "d-hooks", label: "Webhooks reference", category: "Developers", scope: "Docs", hint: "docs/api", kind: "code" },
{ id: "p-ava", label: "Ava Chen", category: "Design", scope: "People", hint: "Product Designer", kind: "person" },
{ id: "p-lena", label: "Lena Kowalski", category: "Design", scope: "People", hint: "Design Systems", kind: "person" },
{ id: "p-marcus", label: "Marcus Reed", category: "Engineering", scope: "People", hint: "Frontend Engineer", kind: "person" },
{ id: "p-priya", label: "Priya Nair", category: "Engineering", scope: "People", hint: "Staff Engineer", kind: "person" },
{ id: "p-diego", label: "Diego Santos", category: "Support", scope: "People", hint: "Support Lead", kind: "person" },
{ id: "a-new", label: "Create new project", category: "Actions", scope: "Actions", hint: "Shift N", kind: "bolt" },
{ id: "a-invite", label: "Invite a teammate", category: "Actions", scope: "Actions", hint: "Shift I", kind: "bolt" },
{ id: "a-billing", label: "Open billing settings", category: "Actions", scope: "Actions", hint: "Settings", kind: "bolt" },
{ id: "a-trial", label: "Start a free trial", category: "Actions", scope: "Actions", hint: "Upgrade", kind: "bolt" },
];
const CITY_ITEMS: Item[] = [
{ id: "c-sf", label: "San Francisco", category: "Americas", scope: "Cities", hint: "PST · UTC-8", kind: "pin" },
{ id: "c-ny", label: "New York", category: "Americas", scope: "Cities", hint: "EST · UTC-5", kind: "pin" },
{ id: "c-sp", label: "São Paulo", category: "Americas", scope: "Cities", hint: "BRT · UTC-3", kind: "pin" },
{ id: "c-lon", label: "London", category: "Europe", scope: "Cities", hint: "GMT · UTC+0", kind: "pin" },
{ id: "c-ber", label: "Berlin", category: "Europe", scope: "Cities", hint: "CET · UTC+1", kind: "pin" },
{ id: "c-lag", label: "Lagos", category: "Africa", scope: "Cities", hint: "WAT · UTC+1", kind: "pin" },
{ id: "c-mum", label: "Mumbai", category: "Asia", scope: "Cities", hint: "IST · UTC+5:30", kind: "pin" },
{ id: "c-sin", label: "Singapore", category: "Asia", scope: "Cities", hint: "SGT · UTC+8", kind: "pin" },
{ id: "c-tok", label: "Tokyo", category: "Asia", scope: "Cities", hint: "JST · UTC+9", kind: "pin" },
{ id: "c-syd", label: "Sydney", category: "Oceania", scope: "Cities", hint: "AEDT · UTC+11", kind: "pin" },
];
const WORKSPACE_SCOPES = ["All", "Docs", "People", "Actions"] as const;
const WORKSPACE_RECENT = ["Reset password", "Webhooks", "Ava Chen"] as const;
/* ------------------------------------------------------------------ */
/* Icons (inline SVG, no libraries) */
/* ------------------------------------------------------------------ */
function Svg({ children, className }: { children: ReactNode; className?: string }) {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.7}
strokeLinecap="round"
strokeLinejoin="round"
className={className}
aria-hidden="true"
>
{children}
</svg>
);
}
function ResultIcon({ kind, className }: { kind: IconKind; className?: string }) {
switch (kind) {
case "book":
return (
<Svg className={className}>
<path d="M6 4h11a1 1 0 0 1 1 1v14H7a3 3 0 0 0-3 3V7a3 3 0 0 1 3-3Z" />
<path d="M4 19a3 3 0 0 1 3-3h11" />
</Svg>
);
case "user":
return (
<Svg className={className}>
<path d="M12 3 5 6v5c0 4.2 2.9 7.4 7 8.7 4.1-1.3 7-4.5 7-8.7V6l-7-3Z" />
<path d="m9 11.5 2 2 3.5-3.8" />
</Svg>
);
case "card":
return (
<Svg className={className}>
<rect x="3" y="5" width="18" height="14" rx="2" />
<path d="M3 10h18" />
</Svg>
);
case "link":
return (
<Svg className={className}>
<path d="M10 14a4 4 0 0 0 5.7 0l2.3-2.3a4 4 0 0 0-5.7-5.7L11 7.3" />
<path d="M14 10a4 4 0 0 0-5.7 0L6 12.3a4 4 0 0 0 5.7 5.7L13 16.7" />
</Svg>
);
case "code":
return (
<Svg className={className}>
<path d="m9 8-4 4 4 4" />
<path d="m15 8 4 4-4 4" />
</Svg>
);
case "person":
return (
<Svg className={className}>
<circle cx="12" cy="8" r="3.5" />
<path d="M5.5 20a6.5 6.5 0 0 1 13 0" />
</Svg>
);
case "bolt":
return (
<Svg className={className}>
<path d="M13 3 5 13h5l-1 8 8-11h-5l1-7Z" />
</Svg>
);
case "pin":
return (
<Svg className={className}>
<path d="M12 21s6.5-5.8 6.5-10.5a6.5 6.5 0 1 0-13 0C5.5 15.2 12 21 12 21Z" />
<circle cx="12" cy="10.5" r="2.3" />
</Svg>
);
}
}
function SearchIcon({ className }: { className?: string }) {
return (
<Svg className={className}>
<circle cx="11" cy="11" r="7" />
<path d="m20 20-3.4-3.4" />
</Svg>
);
}
function ClearIcon({ className }: { className?: string }) {
return (
<Svg className={className}>
<path d="M7 7l10 10M17 7 7 17" />
</Svg>
);
}
function ClockIcon({ className }: { className?: string }) {
return (
<Svg className={className}>
<circle cx="12" cy="12" r="8.5" />
<path d="M12 7.5V12l3 2" />
</Svg>
);
}
function Spinner({ className }: { className?: string }) {
return (
<svg
viewBox="0 0 24 24"
fill="none"
className={`iss-spin ${className ?? ""}`}
aria-hidden="true"
>
<circle cx="12" cy="12" r="9" stroke="currentColor" strokeWidth={2} className="opacity-20" />
<path d="M21 12a9 9 0 0 0-9-9" stroke="currentColor" strokeWidth={2} strokeLinecap="round" />
</svg>
);
}
/* ------------------------------------------------------------------ */
/* Helpers */
/* ------------------------------------------------------------------ */
function highlight(text: string, query: string, markClass: string): ReactNode {
const q = query.trim();
if (!q) return text;
const idx = text.toLowerCase().indexOf(q.toLowerCase());
if (idx === -1) return text;
return (
<>
{text.slice(0, idx)}
<mark className={`rounded-[3px] px-0.5 ${markClass}`}>{text.slice(idx, idx + q.length)}</mark>
{text.slice(idx + q.length)}
</>
);
}
/* ------------------------------------------------------------------ */
/* Search field (accessible combobox) */
/* ------------------------------------------------------------------ */
interface SearchFieldProps {
label: string;
hint: string;
placeholder: string;
items: Item[];
accent: Accent;
emptyHint: string;
scopes?: readonly string[];
recent?: readonly string[];
hotkey?: boolean;
}
function SearchField({
label,
hint,
placeholder,
items,
accent,
emptyHint,
scopes,
recent,
hotkey = false,
}: SearchFieldProps) {
const uid = useId();
const inputId = `${uid}-input`;
const listboxId = `${uid}-listbox`;
const statusId = `${uid}-status`;
const optionId = (i: number) => `${uid}-opt-${i}`;
const prefersReduced = useReducedMotion();
const tokens = ACCENTS[accent];
const [query, setQuery] = useState("");
const [open, setOpen] = useState(false);
const [active, setActive] = useState(-1);
const [loading, setLoading] = useState(false);
const [selected, setSelected] = useState<Item | null>(null);
const [scope, setScope] = useState<string>(scopes ? scopes[0] : "All");
const containerRef = useRef<HTMLDivElement | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
const scopeRefs = useRef<(HTMLButtonElement | null)[]>([]);
const filtered = useMemo(() => {
const base =
scopes && scope !== scopes[0] ? items.filter((i) => i.scope === scope) : items;
const q = query.trim().toLowerCase();
if (!q) return base;
return base.filter(
(i) =>
i.label.toLowerCase().includes(q) ||
i.category.toLowerCase().includes(q) ||
i.hint.toLowerCase().includes(q),
);
}, [items, scopes, scope, query]);
const groups = useMemo(() => {
const map = new Map<string, Item[]>();
for (const item of filtered) {
const arr = map.get(item.category) ?? [];
arr.push(item);
map.set(item.category, arr);
}
return Array.from(map, ([category, list]) => ({ category, list }));
}, [filtered]);
const indexById = useMemo(() => {
const m = new Map<string, number>();
filtered.forEach((it, i) => m.set(it.id, i));
return m;
}, [filtered]);
const showRecent = query.trim() === "" && !!recent && recent.length > 0;
/* simulated live fetch — purely cosmetic, keeps results visible */
useEffect(() => {
if (query.trim() === "") {
setLoading(false);
return;
}
setLoading(true);
const t = setTimeout(() => setLoading(false), 240);
return () => clearTimeout(t);
}, [query]);
/* keep active index in range */
useEffect(() => {
if (active >= filtered.length) setActive(-1);
}, [filtered.length, active]);
/* scroll the active option into view */
useEffect(() => {
if (active < 0) return;
const el = document.getElementById(optionId(active));
el?.scrollIntoView({ block: "nearest" });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [active]);
/* close on outside pointer */
useEffect(() => {
if (!open) return;
function onDown(e: PointerEvent) {
if (!containerRef.current?.contains(e.target as Node)) {
setOpen(false);
setActive(-1);
}
}
document.addEventListener("pointerdown", onDown);
return () => document.removeEventListener("pointerdown", onDown);
}, [open]);
/* "/" hotkey to focus */
useEffect(() => {
if (!hotkey) return;
function onKey(e: KeyboardEvent) {
if (e.key !== "/") return;
const el = document.activeElement;
const typing =
el instanceof HTMLInputElement ||
el instanceof HTMLTextAreaElement ||
(el instanceof HTMLElement && el.isContentEditable);
if (typing) return;
e.preventDefault();
inputRef.current?.focus();
setOpen(true);
}
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [hotkey]);
function commit(item: Item) {
setSelected(item);
setQuery(item.label);
setOpen(false);
setActive(-1);
inputRef.current?.focus();
}
function onInputKeyDown(e: ReactKeyboardEvent<HTMLInputElement>) {
switch (e.key) {
case "ArrowDown":
e.preventDefault();
if (!open) {
setOpen(true);
return;
}
if (filtered.length) setActive((a) => (a + 1) % filtered.length);
break;
case "ArrowUp":
e.preventDefault();
if (!open) {
setOpen(true);
return;
}
if (filtered.length) setActive((a) => (a <= 0 ? filtered.length - 1 : a - 1));
break;
case "Enter": {
if (!open) break;
const target = active >= 0 ? filtered[active] : filtered[0];
if (target) {
e.preventDefault();
commit(target);
}
break;
}
case "Escape":
if (open) {
e.preventDefault();
setOpen(false);
setActive(-1);
} else if (query) {
setQuery("");
}
break;
case "Home":
if (open && filtered.length) {
e.preventDefault();
setActive(0);
}
break;
case "End":
if (open && filtered.length) {
e.preventDefault();
setActive(filtered.length - 1);
}
break;
case "Tab":
setOpen(false);
break;
}
}
function pickScope(next: string, focusIndex: number) {
setScope(next);
setActive(-1);
setOpen(true);
scopeRefs.current[focusIndex]?.focus();
}
return (
<div ref={containerRef} className="relative">
<div className="flex items-baseline justify-between gap-3">
<label
htmlFor={inputId}
className="text-sm font-medium text-slate-700 dark:text-slate-200"
>
{label}
</label>
<span className="text-xs text-slate-400 dark:text-slate-500">{hint}</span>
</div>
{scopes && (
<div
role="radiogroup"
aria-label={`${label} scope`}
className="mt-3 inline-flex items-center gap-1 rounded-full border border-slate-200 bg-slate-100/70 p-1 dark:border-slate-800 dark:bg-slate-800/60"
>
{scopes.map((s, i) => {
const checked = scope === s;
return (
<button
key={s}
ref={(node) => {
scopeRefs.current[i] = node;
}}
type="button"
role="radio"
aria-checked={checked}
tabIndex={checked ? 0 : -1}
onClick={() => pickScope(s, i)}
onKeyDown={(e) => {
if (e.key === "ArrowRight" || e.key === "ArrowDown") {
e.preventDefault();
const n = (i + 1) % scopes.length;
pickScope(scopes[n], n);
} else if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
e.preventDefault();
const n = (i - 1 + scopes.length) % scopes.length;
pickScope(scopes[n], n);
} else if (e.key === "Home") {
e.preventDefault();
pickScope(scopes[0], 0);
} else if (e.key === "End") {
e.preventDefault();
pickScope(scopes[scopes.length - 1], scopes.length - 1);
}
}}
className={`rounded-full px-3.5 py-1 text-xs font-semibold outline-none transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900 ${tokens.ring} ${
checked
? tokens.scopeActive
: "text-slate-600 hover:bg-white hover:text-slate-900 dark:text-slate-300 dark:hover:bg-slate-700/60 dark:hover:text-white"
}`}
>
{s}
</button>
);
})}
</div>
)}
{/* input */}
<div
className={`mt-3 flex items-center gap-2.5 rounded-xl border border-slate-300 bg-white px-3.5 transition-shadow dark:border-slate-700 dark:bg-slate-900 ${tokens.focusWithin}`}
>
<SearchIcon className="h-[18px] w-[18px] shrink-0 text-slate-400 dark:text-slate-500" />
<input
id={inputId}
ref={inputRef}
type="text"
role="combobox"
inputMode="search"
autoComplete="off"
spellCheck={false}
aria-expanded={open}
aria-controls={listboxId}
aria-autocomplete="list"
aria-haspopup="listbox"
aria-describedby={statusId}
aria-activedescendant={open && active >= 0 ? optionId(active) : undefined}
value={query}
placeholder={placeholder}
onChange={(e) => {
setQuery(e.target.value);
setOpen(true);
setActive(-1);
setSelected(null);
}}
onFocus={() => setOpen(true)}
onKeyDown={onInputKeyDown}
className={`h-12 w-full bg-transparent text-[15px] text-slate-900 placeholder:text-slate-400 focus:outline-none dark:text-slate-50 dark:placeholder:text-slate-500 ${tokens.caret}`}
/>
{loading && <Spinner className={`h-[18px] w-[18px] shrink-0 ${tokens.spin}`} />}
{query && !loading && (
<button
type="button"
aria-label="Clear search"
onClick={() => {
setQuery("");
setSelected(null);
setActive(-1);
setOpen(true);
inputRef.current?.focus();
}}
className={`grid h-6 w-6 shrink-0 place-items-center rounded-md text-slate-400 outline-none transition-colors hover:bg-slate-100 hover:text-slate-600 focus-visible:ring-2 dark:hover:bg-slate-800 dark:hover:text-slate-200 ${tokens.ring}`}
>
<ClearIcon className="h-4 w-4" />
</button>
)}
</div>
{/* selected confirmation */}
{selected && !open && (
<div className="mt-3 flex items-center gap-2 text-sm">
<span
className={`inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium ${tokens.pill}`}
>
<ResultIcon kind={selected.kind} className="h-3.5 w-3.5" />
{selected.label}
</span>
<span className="text-slate-400 dark:text-slate-500">selected</span>
</div>
)}
<span id={statusId} role="status" aria-live="polite" className="sr-only">
{open
? `${filtered.length} suggestion${filtered.length === 1 ? "" : "s"} available`
: ""}
</span>
{/* dropdown */}
<AnimatePresence initial={false}>
{open && (
<motion.div
key="panel"
initial={prefersReduced ? { opacity: 0 } : { opacity: 0, y: -6 }}
animate={{ opacity: 1, y: 0 }}
exit={prefersReduced ? { opacity: 0 } : { opacity: 0, y: -6 }}
transition={{ duration: prefersReduced ? 0.001 : 0.16, ease: "easeOut" }}
className="absolute left-0 right-0 z-30 mt-2 overflow-hidden rounded-xl border border-slate-200 bg-white shadow-xl shadow-slate-900/10 dark:border-slate-700 dark:bg-slate-900 dark:shadow-black/40"
>
{showRecent && (
<div className="flex flex-wrap items-center gap-2 border-b border-slate-100 px-3 py-2.5 dark:border-slate-800">
<span className="inline-flex items-center gap-1.5 text-xs font-medium text-slate-400 dark:text-slate-500">
<ClockIcon className="h-3.5 w-3.5" />
Recent
</span>
{recent!.map((r) => (
<button
key={r}
type="button"
onClick={() => {
setQuery(r);
setActive(-1);
inputRef.current?.focus();
}}
className={`rounded-full border border-slate-200 px-2.5 py-0.5 text-xs text-slate-600 outline-none transition-colors focus-visible:ring-2 dark:border-slate-700 dark:text-slate-300 ${tokens.chipHover} ${tokens.ring}`}
>
{r}
</button>
))}
</div>
)}
<ul
id={listboxId}
role="listbox"
aria-label={`${label} suggestions`}
className="iss-fade max-h-72 overflow-y-auto overscroll-contain py-1.5"
>
{filtered.length === 0 ? (
<li role="presentation" className="px-4 py-8 text-center">
<p className="text-sm font-medium text-slate-700 dark:text-slate-200">
No matches for “{query.trim()}”
</p>
<p className="mt-1 text-xs text-slate-400 dark:text-slate-500">{emptyHint}</p>
</li>
) : (
groups.map((group) => (
<li key={group.category} role="presentation">
<p className="px-3.5 pb-1 pt-2 text-[11px] font-semibold uppercase tracking-wide text-slate-400 dark:text-slate-500">
{group.category}
</p>
<ul role="presentation">
{group.list.map((item) => {
const i = indexById.get(item.id) ?? -1;
const isActive = active === i;
return (
<li
key={item.id}
id={optionId(i)}
role="option"
aria-selected={isActive}
data-active={isActive}
onMouseEnter={() => setActive(i)}
onMouseDown={(e) => e.preventDefault()}
onClick={() => commit(item)}
className={`relative mx-1.5 flex cursor-pointer items-center gap-3 rounded-lg px-2.5 py-2 ${
isActive ? tokens.optActive : ""
}`}
>
{isActive && (
<span
aria-hidden="true"
className={`absolute left-0 top-1/2 h-5 w-[3px] -translate-y-1/2 rounded-full ${tokens.optBar}`}
/>
)}
<span
className={`grid h-8 w-8 shrink-0 place-items-center rounded-lg bg-slate-100 dark:bg-slate-800 ${
isActive ? tokens.iconTint : "text-slate-500 dark:text-slate-400"
}`}
>
<ResultIcon kind={item.kind} className="h-[18px] w-[18px]" />
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm text-slate-800 dark:text-slate-100">
{highlight(item.label, query, tokens.mark)}
</span>
<span className="block truncate text-xs text-slate-400 dark:text-slate-500">
{item.hint}
</span>
</span>
{isActive && (
<span className="hidden shrink-0 items-center gap-1 text-[11px] text-slate-400 dark:text-slate-500 sm:flex">
<kbd className="rounded border border-slate-300 bg-slate-50 px-1.5 py-0.5 font-sans dark:border-slate-600 dark:bg-slate-800">
↵
</kbd>
open
</span>
)}
</li>
);
})}
</ul>
</li>
))
)}
</ul>
<div className="flex items-center justify-between gap-3 border-t border-slate-100 px-3.5 py-2 text-[11px] text-slate-400 dark:border-slate-800 dark:text-slate-500">
<span>
{filtered.length} result{filtered.length === 1 ? "" : "s"}
</span>
<span className="hidden items-center gap-2 sm:flex">
<span className="inline-flex items-center gap-1">
<kbd className="rounded border border-slate-300 bg-slate-50 px-1 py-0.5 font-sans dark:border-slate-600 dark:bg-slate-800">
↑
</kbd>
<kbd className="rounded border border-slate-300 bg-slate-50 px-1 py-0.5 font-sans dark:border-slate-600 dark:bg-slate-800">
↓
</kbd>
navigate
</span>
<span className="inline-flex items-center gap-1">
<kbd className="rounded border border-slate-300 bg-slate-50 px-1.5 py-0.5 font-sans dark:border-slate-600 dark:bg-slate-800">
esc
</kbd>
close
</span>
</span>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
/* ------------------------------------------------------------------ */
/* Showcase */
/* ------------------------------------------------------------------ */
const STYLES = `
@keyframes iss_spin { to { transform: rotate(360deg); } }
@keyframes iss_fadein { from { opacity: 0; transform: translateY(2px); } to { opacity: 1; transform: translateY(0); } }
.iss-spin { animation: iss_spin 0.7s linear infinite; transform-origin: center; }
.iss-fade { animation: iss_fadein 0.22s ease-out both; }
@media (prefers-reduced-motion: reduce) {
.iss-spin { animation: none; }
.iss-fade { animation: none; }
}
`;
export default function SearchSuggest() {
return (
<section className="relative w-full bg-slate-50 px-4 py-20 text-slate-900 dark:bg-slate-950 dark:text-slate-50 sm:py-28">
<style>{STYLES}</style>
<div className="mx-auto max-w-xl">
<header>
<span className="inline-flex items-center gap-2 rounded-full border border-slate-200 bg-white px-3 py-1 text-xs font-medium text-slate-500 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-400">
<span className="h-1.5 w-1.5 rounded-full bg-emerald-500" />
Live suggestions
</span>
<h2 className="mt-4 text-3xl font-semibold tracking-tight text-slate-900 dark:text-white sm:text-4xl">
Search that answers as you type
</h2>
<p className="mt-3 text-[15px] leading-relaxed text-slate-500 dark:text-slate-400">
A fully keyboard-navigable combobox: arrow keys to move, Enter to open, Escape to
dismiss. Filter by scope, browse recents, and see matches highlighted in real time.
</p>
</header>
{/* Variant 1 — full workspace search with scopes + recents */}
<div className="relative z-20 mt-10 rounded-2xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900 sm:p-6">
<SearchField
label="Search the workspace"
hint="Press / to focus"
placeholder="Search docs, people, and actions…"
items={WORKSPACE_ITEMS}
accent="indigo"
scopes={WORKSPACE_SCOPES}
recent={WORKSPACE_RECENT}
emptyHint="Try a teammate's name, a doc title, or an action."
hotkey
/>
</div>
{/* Variant 2 — compact single-purpose picker */}
<div className="relative z-10 mt-6 rounded-2xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900 sm:p-6">
<SearchField
label="Add a city to your world clock"
hint="10 time zones"
placeholder="Search a city…"
items={CITY_ITEMS}
accent="emerald"
emptyHint="Search by city name or region."
/>
</div>
<p className="mt-8 text-center text-xs text-slate-400 dark:text-slate-600">
Built with real inputs, ARIA combobox roles, and reduced-motion support.
</p>
</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 Input
Originalsearch box with icon and clear

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

