A search field with a results panel that filters as you type. Results are grouped under Pages, People and Actions, the matching letters are highlighted, and each row has an icon or an initials avatar plus a short hint. Arrow keys move the selection, Enter picks it and Escape closes the panel. A footer shows the result count and the keyboard hints. The preview opens with a sample query so the dropdown is visible. On phones the panel is as wide as the field, and the hints and key legend are hidden to keep rows short.
import { useState } from "react";
const items = [
{ group: "Pages", title: "Invoices", hint: "Billing" },
{ group: "Pages", title: "Inventory report", hint: "Reports" },
{ group: "Pages", title: "Payouts", hint: "Billing" },
{ group: "Pages", title: "Customers", hint: "Directory" },
{ group: "Pages", title: "Team settings", hint: "Admin" },
{ group: "People", title: "Carla Invernizzi", hint: "Accounts payable" },
{ group: "People", title: "Jonas Okafor", hint: "Warehouse lead" },
{ group: "People", title: "Mei Tanaka", hint: "Customer support" },
{ group: "Actions", title: "Invite a teammate", hint: "Opens the invite form" },
{ group: "Actions", title: "Create an invoice", hint: "Starts a blank draft" },
{ group: "Actions", title: "Export customers", hint: "Downloads a spreadsheet" },
];
const groupNames = ["Pages", "People", "Actions"];
const icons: Record<string, string> = {
Pages: "M6 3.5h5.5L15 7v9.5H6v-13Zm5 0V7.5h4M8.5 11h4m-4 2.5h4",
Actions: "M11 2.5 4.5 11H10l-1 6.5L15.5 9H10l1-6.5Z",
};
function Match({ text, query }: { text: string; query: string }) {
const at = text.toLowerCase().indexOf(query.toLowerCase());
if (!query || at < 0) return <>{text}</>;
return (
<>
{text.slice(0, at)}
<mark className="rounded-sm bg-rose-100 text-rose-900">{text.slice(at, at + query.length)}</mark>
{text.slice(at + query.length)}
</>
);
}
export default function SearchFieldWithResultsDropdown() {
const [query, setQuery] = useState("inv");
const [open, setOpen] = useState(true);
const [active, setActive] = useState(0);
const q = query.trim();
const results = groupNames.flatMap((g) => items.filter((it) => it.group === g && it.title.toLowerCase().includes(q.toLowerCase())));
const showList = open && q.length > 0;
const choose = (title: string) => {
setQuery(title);
setOpen(false);
};
const onKeyDown = (e: { key: string; preventDefault: () => void }) => {
if (e.key === "Escape") return setOpen(false);
if (!results.length) return;
if (e.key === "ArrowDown") {
e.preventDefault();
setOpen(true);
setActive((active + 1) % results.length);
} else if (e.key === "ArrowUp") {
e.preventDefault();
setActive((active - 1 + results.length) % results.length);
} else if (e.key === "Enter" && showList) {
e.preventDefault();
choose(results[Math.min(active, results.length - 1)].title);
}
};
return (
<div className="relative h-[540px] overflow-hidden bg-stone-100 px-4 pt-10 text-stone-900 sm:pt-14">
<div aria-hidden="true" className="absolute inset-x-4 top-36 mx-auto grid max-w-xl gap-3 sm:top-40">
{["w-2/3", "w-1/2", "w-3/5", "w-2/5"].map((w, i) => (
<div key={i} className="flex items-center gap-4 rounded-xl bg-white p-4">
<span className="h-9 w-9 flex-none rounded-lg bg-stone-100" />
<span className="flex-1 space-y-2">
<span className={`block h-2.5 rounded-full bg-stone-200 ${w}`} />
<span className="block h-2 w-1/3 rounded-full bg-stone-100" />
</span>
</div>
))}
</div>
<div className="relative mx-auto max-w-xl">
<label htmlFor="search-field" className="sr-only">Search the workspace</label>
<div className="relative">
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" aria-hidden="true" className="pointer-events-none absolute left-4 top-1/2 h-5 w-5 -translate-y-1/2 text-stone-600">
<circle cx="9" cy="9" r="5.5" />
<path d="m13.5 13.5 3.5 3.5" />
</svg>
<input
id="search-field"
type="text"
role="combobox"
aria-expanded={showList}
aria-controls="search-results"
aria-autocomplete="list"
aria-activedescendant={showList && results.length ? `search-option-${Math.min(active, results.length - 1)}` : undefined}
autoComplete="off"
placeholder="Search pages, people and actions"
value={query}
onChange={(e) => {
setQuery(e.target.value);
setActive(0);
setOpen(true);
}}
onFocus={() => setOpen(true)}
onKeyDown={onKeyDown}
className="block h-12 w-full rounded-xl border border-stone-300 bg-white pl-12 pr-12 text-base text-stone-900 shadow-sm placeholder:text-stone-400 focus:border-rose-600 focus:outline-none focus:ring-2 focus:ring-rose-600/20"
/>
{query && (
<button
type="button"
aria-label="Clear search"
onClick={() => {
setQuery("");
setActive(0);
}}
className="absolute right-1 top-1 flex h-10 w-10 items-center justify-center rounded-lg text-stone-600 transition hover:bg-stone-100 hover:text-stone-900 focus:outline-none focus-visible:ring-2 focus-visible:ring-rose-600"
>
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" aria-hidden="true" className="h-4 w-4">
<path d="m5 5 10 10M15 5 5 15" />
</svg>
</button>
)}
</div>
{showList && (
<div className="absolute inset-x-0 top-full mt-2 overflow-hidden rounded-xl border border-stone-200 bg-white shadow-xl shadow-stone-900/10">
{results.length === 0 ? (
<p id="search-results" role="status" className="px-5 py-8 text-center text-sm text-stone-600">
Nothing matches <span className="font-semibold text-stone-900">{q}</span>. Try a page name or a person.
</p>
) : (
<div id="search-results" role="listbox" aria-label="Search results" className="max-h-96 overflow-y-auto p-2">
{groupNames.map((g) => {
const inGroup = results.filter((r) => r.group === g);
if (!inGroup.length) return null;
return (
<div key={g} role="group" aria-label={g} className="pb-1">
<p aria-hidden="true" className="px-3 pb-1 pt-2 text-xs font-semibold uppercase tracking-wider text-stone-600">{g}</p>
{inGroup.map((r) => {
const index = results.indexOf(r);
const selected = index === Math.min(active, results.length - 1);
return (
<div
key={r.title}
id={`search-option-${index}`}
role="option"
aria-selected={selected}
onMouseEnter={() => setActive(index)}
onClick={() => choose(r.title)}
className={`flex min-h-[48px] cursor-pointer items-center gap-3 rounded-lg px-3 py-1.5 ${selected ? "bg-stone-100" : ""}`}
>
{g === "People" ? (
<span aria-hidden="true" className="flex h-8 w-8 flex-none items-center justify-center rounded-full bg-rose-700 text-xs font-semibold text-white">
{r.title.split(" ").map((w) => w[0]).join("")}
</span>
) : (
<span aria-hidden="true" className="flex h-8 w-8 flex-none items-center justify-center rounded-lg border border-stone-200 bg-white text-stone-700">
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" className="h-4 w-4">
<path d={icons[g]} />
</svg>
</span>
)}
<span className="min-w-0 flex-1 truncate text-sm font-medium text-stone-900">
<Match text={r.title} query={q} />
</span>
<span className="hidden flex-none text-xs text-stone-600 sm:block">{r.hint}</span>
{selected && (
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" className="h-4 w-4 flex-none text-stone-600">
<path d="M15 5v4.5a2 2 0 0 1-2 2H5m3-3-3 3 3 3" />
</svg>
)}
</div>
);
})}
</div>
);
})}
</div>
)}
<div className="flex items-center justify-between gap-4 border-t border-stone-200 bg-stone-50 px-4 py-2.5 text-xs text-stone-600">
<span>{results.length} {results.length === 1 ? "result" : "results"}</span>
<span className="hidden items-center gap-3 sm:flex">
<span><kbd className="rounded border border-stone-300 bg-white px-1.5 py-0.5 font-sans font-medium text-stone-700">Up</kbd> <kbd className="rounded border border-stone-300 bg-white px-1.5 py-0.5 font-sans font-medium text-stone-700">Down</kbd> to move</span>
<span><kbd className="rounded border border-stone-300 bg-white px-1.5 py-0.5 font-sans font-medium text-stone-700">Enter</kbd> to open</span>
<span><kbd className="rounded border border-stone-300 bg-white px-1.5 py-0.5 font-sans font-medium text-stone-700">Esc</kbd> to close</span>
</span>
</div>
</div>
)}
</div>
</div>
);
}
Install with one command: npx shadcn@latest add https://we.inc/r/search-field-with-results-dropdown.json
Replace the `results` line with state that you fill from a request made when `query` changes. Wait about 200 milliseconds after the last keystroke before sending it, and keep the same shape of `group`, `title` and `hint`.
The small `Match` component finds the query inside the title without caring about case, and wraps that part in a `<mark>`. If you search other fields, pass those to it as well.
Add an `onBlur` on the wrapper that calls `setOpen(false)` when the newly focused element is not inside it, or listen for `pointerdown` on the document in an effect.
Here it fills the field and closes the panel. In a real app, change the `choose` function to navigate to the page or run the action.
It is close. A command palette usually opens in a dialog from a keyboard shortcut. You can place this field and panel inside a modal to get that behaviour.
Related: Settings Panel with Toggle Switches and Save Bar, Contact Form with Topic Select and Details Panel, Button Set: Primary, Secondary, Ghost, Icon and Loading.
More inputs or all free UI components. Start for free to have a full site built around it.
We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.