⌘K Command Palette
Press ⌘K (or Ctrl+K) anywhere in Gentelella to open a fuzzy-search modal across all 58 pages plus inline actions. Auto-built from the NAV array. No external library — the matcher is a small subsequence + word-boundary scorer.
Last updated May 22, 2026
The command palette is a global keyboard shortcut — ⌘K on macOS, Ctrl+K on Windows/Linux — that opens a fuzzy-search modal over every page in NAV plus a curated list of inline actions.
It’s also wired into the topbar search box: clicking or focusing the search input opens the palette instead of typing inline. That makes the search field a discoverable affordance for users who don’t try keyboard shortcuts.
The implementation lives in src/v4/command-palette.js — ~250 lines, no external dependencies.
What’s in it
Two kinds of items:
Pages — built from the NAV array in shell-render.js:
NAV.forEach((group) => {
group.items.forEach((it) => {
out.push({
kind: 'page',
label: it.text,
section: group.label,
href: it.href,
keywords: `${it.text} ${group.label} ${it.key}`.toLowerCase()
});
});
});
A nav item like { key: 'kanban', href: 'kanban.html', text: 'Kanban' } in the Apps group becomes a palette result with the label “Kanban”, section tag “Apps”, and search keywords “kanban apps kanban”. Submenu items work too — the palette flattens nested children into individual results.
Actions — a hand-curated list of inline commands that aren’t tied to a page:
const actions = [
{ label: 'Toggle theme', keywords: 'theme dark light mode toggle', action: toggleTheme },
{ label: 'Open profile', keywords: 'profile account user me', action: () => { window.location.href = 'profile.html'; } },
{ label: 'Open settings', keywords: 'settings preferences config', action: () => { window.location.href = 'settings.html'; } },
{ label: 'Theme generator', keywords: 'theme color customize brand', action: () => { window.location.href = 'theme.html'; } },
{ label: 'Help & support', keywords: 'help faq support docs', action: () => { window.location.href = 'faq.html'; } },
{ label: 'Sign out', keywords: 'sign out logout exit', action: () => showModal({ /* confirm dialog */ }) }
];
To add a new action, append to the actions array in command-palette.js with an action function. The handler runs when the user activates the row (click or Enter).
The fuzzy matcher
No external library. The score function is a subsequence matcher with two bonuses:
function score(query, target) {
if (!query) return 0;
const t = target;
const q = query;
let ti = 0, qi = 0, s = 0, lastMatchedAt = -2;
while (qi < q.length && ti < t.length) {
if (t[ti] === q[qi]) {
// Bonus for word boundary
if (ti === 0 || t[ti - 1] === ' ' || t[ti - 1] === '-' || t[ti - 1] === '_') s -= 6;
// Bonus for consecutive
if (lastMatchedAt === ti - 1) s -= 4;
lastMatchedAt = ti;
qi += 1;
} else {
s += 1;
}
ti += 1;
}
if (qi < q.length) return Infinity;
s += (t.length - q.length) * 0.1; // Prefer shorter targets that match
return s;
}
Lower scores win. The two bonuses are what makes it feel right:
- Word-boundary bonus (-6) means typing
kbmatches “Kanban Board” much better than “Markdown”. - Consecutive bonus (-4) means
kanranks “Kanban” above “Kalendar” (typo-tolerant but still prefers exact substrings).
The matcher is “good enough for ~50 items” — fast, no allocations beyond the score itself. If you scale the palette to thousands of items (a fuzzy file finder, say), swap in a real library like fzf-for-js.
Open / close
The global keybinding is installed once via initCommandPalette(), called from main-v4.js:
export function initCommandPalette() {
if (initCommandPalette._wired) return;
initCommandPalette._wired = true;
document.addEventListener('keydown', (e) => {
const isK = e.key === 'k' || e.key === 'K';
if (isK && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
host ? close() : open();
}
});
// Repurpose the topbar search box
const search = document.querySelector('.search-box input');
if (search) {
const opener = (e) => { e.preventDefault(); search.blur(); open(); };
search.addEventListener('focus', opener);
search.addEventListener('click', opener);
search.setAttribute('readonly', '');
search.setAttribute('aria-label', 'Open command palette');
}
}
The _wired flag makes the function idempotent — calling it twice (e.g. from two scripts on the same page) doesn’t double-bind.
The palette DOM is created on first open and torn down on close — no persistent host element in the body, which means it doesn’t interfere with other modals or layout when closed.
Keyboard inside the palette
Once open:
| Key | Action |
|---|---|
| Type | Filter — scores all items, re-renders top results |
| ↑ / ↓ | Move active selection |
| Enter | Activate — navigate to the page or run the action |
| Esc | Close |
| Click backdrop | Close |
The active item gets aria-selected="true" and a visible highlight; it’s the one Enter would activate. Hover overrides selection — mouseenter on a row updates activeIndex, so mouse + keyboard mix smoothly.
Adding a custom action
A common case: integrate the palette with your own app’s commands.
import { openCommandPalette, closeCommandPalette } from '/src/v4/command-palette.js';
// Bind to a different shortcut
document.addEventListener('keydown', (e) => {
if (e.key === 'p' && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
openCommandPalette();
}
});
The exported openCommandPalette / closeCommandPalette are aliases so you can drive the palette programmatically (e.g. open it after a successful action’s confirmation).
For a more invasive change — adding actions from outside the file — edit buildItems() in command-palette.js directly. The actions array isn’t exposed for runtime extension because the palette is a static UI feature, not a plugin host.
Why the topbar search box opens the palette
Two reasons:
- Most admin templates have a “Search” box in the topbar that does nothing. Wiring it to the palette makes the affordance functional without a separate search infrastructure.
- Users who don’t try ⌘K still discover the palette. Click “Search…”, get a fuzzy-matching modal — same UX, no learning curve.
The downside: there’s no separate “global search across documents” feature in the template. If your app needs that (search across emails, projects, customers, etc.), you’d build it as a separate page or backend-driven endpoint — the palette is intentionally limited to navigation + commands.
See also
- Architecture — how
main-v4.jsbootsinitCommandPalette()alongside other shell behaviors - Adding a page — every new
NAVentry automatically becomes a palette result