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.

      760px

        <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.