Keyboard-first search
Global search is Command in a Dialog on ⌘K.
From shadcn (Vercel plugin): Reach for this first, and the search recipe.
Reach Settings › Billing
Reach Billing in each app, keyboard only if you can.
Search box + resultsBad
0.0 s · 0 keys · 0 clicks
Command in a DialogGood
0.0 s · 0 keys · 0 clicks
export function GlobalSearch() {
const [open, setOpen] = React.useState(false)
React.useEffect(() => {
const down = (e: KeyboardEvent) => {
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
e.preventDefault()
setOpen((o) => !o)
}
}
document.addEventListener("keydown", down)
return () => document.removeEventListener("keydown", down)
}, [])
return (
<>
{/* Keep a visible trigger: mouse users never guess a shortcut */}
<Button variant="outline" onClick={() => setOpen(true)}>
Search… <kbd>⌘K</kbd>
</Button>
<CommandDialog open={open} onOpenChange={setOpen}>
<CommandInput placeholder="Search pages, settings, actions…" />
<CommandList>
<CommandEmpty>No results.</CommandEmpty>
<CommandGroup heading="Settings">
<CommandItem
value="Billing"
keywords={["settings", "plan", "card", "payment"]}
onSelect={() => { setOpen(false); router.push("/settings/billing") }}
>
Billing
</CommandItem>
</CommandGroup>
</CommandList>
</CommandDialog>
</>
)
}
Substring vs fuzzy match
Type a query or pick a chip; try a typo.
label.includes(query)Bad
Fuzzy, ranked (cmdk)Good
function score(haystack: string, query: string) {
let s = 0, from = 0, prev = -2
for (const ch of query) {
const i = haystack.indexOf(ch, from)
if (i < 0) return 0
s += 1
if (i === prev + 1) s += 3
if (i === 0 || haystack[i - 1] === " ") s += 4
s -= Math.min(i - from, 8) * 0.15
prev = i; from = i + 1
}
return s
}
<Command filter={(value, search, keywords) =>
score([value, ...(keywords ?? [])].join(" ").toLowerCase(), search.toLowerCase())
} />
Popover pickers, Sheet filters
Pick a customer, then narrow the frame below 640px.
Filters
Status
Currency
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button variant="outline" role="combobox" aria-expanded={open}>
{customer?.name ?? "All customers"}
</Button>
</PopoverTrigger>
<PopoverContent className="w-64 p-0" align="start">
<Command>
<CommandInput placeholder="Search customers…" />
<CommandList>
<CommandEmpty>No customer found.</CommandEmpty>
{customers.map((c) => (
<CommandItem key={c.id} value={c.name} onSelect={() => { setCustomer(c); setOpen(false) }}>
{c.name}
</CommandItem>
))}
</CommandList>
</Command>
</PopoverContent>
</Popover>
<div className="hidden md:flex gap-2"><StatusFilter /><CurrencyFilter /></div>
<Sheet>
<SheetTrigger asChild><Button variant="outline" className="md:hidden">Filters</Button></SheetTrigger>
<SheetContent side="bottom">
<SheetHeader><SheetTitle>Filters</SheetTitle></SheetHeader>
<StatusFilter /><CurrencyFilter />
</SheetContent>
</Sheet>
Why it matters, pros and cons
Why it matters
Searchers know the destination. A chord, a few letters and Enter get them there.
Pros
- No pointer travel.
- Typos and abbreviations still rank first.
- One model for search and pickers.
Cons
- Invisible to mouse users without a visible trigger.
- ⌘K collides with editor shortcuts.
- Not a results page for comparing or sharing.