A ⌘K palette is the highest-use UI element in any SaaS dashboard. Press two keys, type three letters, jump to any record, page, or action in the product. It collapses the whole navigation tree into a single 200-millisecond thought, and once a user has one, they never reach for the sidebar again.
A real palette also tells you something about a template. It tells you whether the product was built to be looked at for 30 seconds in a sales demo or lived in for eight hours a day. The test is simple: open the palette, type the id of a record you saw earlier, and see whether you land on it or whether the palette just shrugs.
What a real command palette actually does
The common mistake is to treat ⌘K as a "search box modal". It isn't. A command palette is a fused nav + lookup + action surface. Three things, one input:
-
Navigation — jump to a page (
/dashboard/billing,/dashboard/settings). - Lookup — find a record by id, name, email — across every entity type.
-
Action — run a verb (
Create customer,Export invoices,Invite teammate).
Most palettes ship one of these and call it done. A real palette ships all three, distinguished by icon and section header, grouped so a user can scan the answer rather than parse it.
The contract is also strict. Users expect ⌘K (or Ctrl+K) to open, Esc to close, ↑/↓ to navigate, Enter to select, and nothing else to surprise them. The moment a palette opens with a custom focus animation, debounces input by 400ms, or rebinds a key the user already uses elsewhere, it breaks the muscle memory they've trained on every other SaaS tool they touch.
Why "every entity type" is the bar
A palette that only searches nav pages is a settings shortcut — useful, not transformative. A palette that only searches customers is a customer picker — useful, not transformative. The bar is the union: every entity type a user thinks in (customers, invoices, projects, subscriptions, tickets, support docs), plus actions, plus pages, all in one keystroke.
type PaletteResult = {
id: string
kind: 'entity' | 'action' | 'page'
group: string // "Customers", "Invoices", "Actions"
label: string // what shows in the list
description?: string // secondary line
href?: string // for navigation
run?: () => void | Promise<void> // for actions
keywords?: string[] // extra fuzz tokens
}
type PaletteIndex = {
query: (q: string, limit?: number) => Promise<PaletteResult[]>
recent: () => PaletteResult[] // last 8, in-memory
}
This is the spine. Customers, invoices, projects, settings, help — each is just a different query() function that contributes results. The palette doesn't care how lookup happens. A 5k-customer shop uses an in-memory fuzzy index (MiniSearch is fine). A 500k-customer shop issues a server search behind the same interface, and the palette's UI never changes.
The three signals that a template shipped a demo
Watch for these — they tell you the template was built for a screenshot, not a Tuesday morning.
1. The search box searches the table, not the system. A type="search" at the top of an invoice table that filters the rows beneath it is a column filter, not a command palette. Real users with 200 invoices want to jump to one, not filter the visible 20.
2. The palette is hard-coded to nav items only. Press ⌘K and you see Dashboard / Settings / Logout. Access to records is still three clicks deep. This is a settings shortcut with extra steps.
3. There is no recent items list. A palette without recents is a palette that doesn't learn. The fourth-most-capable UI element in any tool is "what you opened yesterday" — and it's free to ship.
| Tell | Demo template | Lived-in tool |
|---|---|---|
| Search box at top of tables | Filters columns | Removed — palette covers it |
| Clicks to find a specific record | 4–6 (nav → list → filter → row) | 1 (⌘K + three letters) |
| Recent items | None | Last 8 in palette, persisted |
| Adding a new entity type (e.g., "tickets") | New sidebar item + page | One registerEntity call |
| First action of the morning | Login, find sidebar, click | Login, ⌘K, type |
The row that matters is the last one. The tab people open first is the tab they've wired into muscle memory.
[[CONCEPT: a single keyboard-driven command palette, available on every dashboard page, that fuses navigation, lookup, and actions into one keystroke]]
The keyboard contract
If you do nothing else, ship this contract exactly. Every palette in the wild obeys it; deviating is a UX tax you collect on every keypress.
// global shortcut — ⌘K / Ctrl-K to open, Esc to close
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
const isMod = e.metaKey || e.ctrlKey
if (isMod && e.key.toLowerCase() === 'k') {
e.preventDefault()
palette.toggle()
}
if (e.key === 'Escape' && palette.isOpen) {
palette.close()
}
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [])
Inside the palette:
-
↑/↓— move the active result -
Enter— run the active result (open href or callrun()) -
⌘Enter— open in a new tab if the result has an href -
Tab/Shift+Tab— jump between input, results, and any footer action -
/(when palette is open) — re-focus the input
Accessibility is part of the contract, not a polish pass:
<div role="combobox" aria-expanded={open} aria-haspopup="listbox"
aria-controls="palette-listbox" aria-activedescendant={activeId}>
<input aria-label="Search customers, invoices, pages, or run a command"
aria-autocomplete="list" aria-controls="palette-listbox" />
<ul id="palette-listbox" role="listbox">
{results.map(r => (
<li id={r.id} role="option" aria-selected={r.id === activeId}>
...
</li>
))}
</ul>
</div>
A palette that doesn't pass a screen-reader smoke test is a palette that doesn't ship.
How OTF's SaaS Dashboard kit wires it
The SaaS Dashboard kit — the full-stack web kit with auth, billing, DB, and Stripe pre-wired (one of three paid kits in OTF, alongside a free MIT SDK of ~200 components) — ships command palette as a first-class surface, not a plugin you bolt on later.
The wiring is one import and one provider at the route root:
// app/dashboard/layout.tsx
// Component names + import path are in the kit README.
import { CommandPalette, registerEntity } from '@otfdashkit/saas-dashboard'
registerEntity({
kind: 'subscription',
group: 'Subscriptions',
query: async (q) => {
const rows = await db.subscriptions.search(q)
return rows.map(s => ({
id: `sub:${s.id}`,
kind: 'entity',
group: 'Subscriptions',
label: s.plan,
description: `${s.customer.name} · $${s.amount / 100}/mo`,
href: `/dashboard/subscriptions/${s.id}`,
keywords: [s.customer.email, s.plan, s.status],
}))
},
})
export default function Layout({ children }) {
return (
<>
{children}
<CommandPalette /> {/* ⌘K is wired inside */}
</>
)
}
That's it. customers, invoices, projects, settings, and help are registered by default; you can swap any of them out. Adding a new entity type is one registerEntity call. The kit handles the keyboard shortcut, the focus trap, the recent-items list (last 8, persisted to localStorage), the a11y wiring, and result ranking (exact id > prefix > fuzzy > keyword).
The local index runs against a prebuilt MiniSearch instance per entity, hydrated at mount. For entities above ~5k rows, swap query() for a server endpoint and the palette stays unchanged.
The performance floor
A palette that takes longer than ~150ms to render results feels broken. The moment a user types and the UI lags, they've already reached for the mouse.
Two habits keep it fast:
// 1. debounce by keystroke, not by timer
// re-query on every keystroke against a local index (it's free)
// debounce only when the backend is involved
const onChange = (e: ChangeEvent<HTMLInputElement>) => {
setQuery(e.target.value)
if (backendSearch) debounce(() => runQuery(e.target.value), 80)
else runQuery(e.target.value)
}
// 2. cap results per group, not globally
const rank = (results: PaletteResult[]) => {
const byGroup = new Map<string, PaletteResult[]>()
for (const r of results) {
if (!byGroup.has(r.group)) byGroup.set(r.group, [])
if (byGroup.get(r.group)!.length < 5) byGroup.get(r.group)!.push(r)
}
return [...byGroup.values()].flat()
}
Capping per group stops a 10k-customer database from flooding the list and starving every other entity type of visibility. The group cap of 5 is the Spotify / Linear / Raycast convention; it also keeps the dropdown from scrolling, which is the cue that tells a user "yes, your answer is here."
What this enables
A command palette changes the shape of daily use. People who use ⌘K stop using the sidebar. Their click-count per task drops from 4–6 to 1. They come back to the product not because the navigation is pretty but because the navigation is fast — and they attribute that speed to the tool itself.
The compounding effect on retention is real and well-documented in tools that ship one. A tool that loads in 200ms on any page, for any record, for any action, becomes the tab people open first thing in the morning. The SaaS dashboard that ships a palette is a tool people live in. The SaaS dashboard that doesn't is a template people re-deploy and forget.
If you ship one — and you should, before you ship a third pricing page — register the entities users actually think in, ship the keyboard contract, and let the palette do the talking. The demo writes itself.
Top comments (0)