A zero-dependency Ctrl/Cmd+K palette with fuzzy search, groups, shortcuts, and full keyboard control. Try it — everything on this page is wired up.
One static call; the palette, hotkey, and styles bootstrap themselves lazily.
CommandPalette.register([
{ id: 'save', label: 'Save file',
hint: 'Ctrl+S', group: 'Actions',
action: function () { save() } }
]) // Ctrl/Cmd+K just works
Case- and diacritic-insensitive subsequence match with word-start and consecutive-run bonuses. Matched letters are highlighted.
Open the palette and type sf → Save file, or resume → Résumé template.
{ id: 'docs', label: 'Open documentation',
keywords: 'help manual guide' }
These commands actually flip this page's theme — the palette (and
the whole family) follows <html data-theme> live.
{ id: 'theme-dark', label: 'Theme: Dark',
group: 'Theme',
action: function () { setTheme('dark') } }
With recent: true, the last five run commands surface
in a “Recent” group — in memory only, per page load.
CommandPalette.defaults.recent = true
Register and unregister at any time; re-registering an id replaces it in place. Disabled commands render grayed out.
CommandPalette.register({ id: 'secret', … })
CommandPalette.unregister('secret')
One file from the CDN. Keyboard: ↑↓ navigate, ↵ run, esc clears the query, then closes.
<script src="https://cdn.jsdelivr.net/gh/ vanilla-ui-kit/components/command/command.js"> </script>
The complete surface of CommandPalette
(v1.0.0), as implemented in command.js. Zero dependencies,
CommonJS/AMD/global builds, SSR-safe (every method is a no-op without a DOM).
var palette = new CommandPalette(options) // options object, optional — every key optional
The constructor stores options (merged over
CommandPalette.defaults), registers any commands
passed in, and binds the global hotkey immediately (unless
hotkey: false). The overlay DOM and stylesheet are only built and
injected on the first open().
Three ways to use it:
CommandPalette.register(command) and the other statics proxy a
lazily created default instance; nothing is constructed or bound until the
first static call. Change any default first via
CommandPalette.defaults.placeholder = '…'.new CommandPalette({ commands: […], hotkey: 'ctrl+shift+p', … })
for a palette with its own options, hotkey, and command set.data-command-open opens the default palette on click
(see Declarative init).Passed to register() (single object or array). Only
id is required — entries without an id are silently
skipped. Re-registering an existing id replaces the old command
in place (list order stays stable).
| Key | Type | Meaning |
|---|---|---|
id | string (required) | Unique key. Coerced to string; re-registering it replaces the command in place. |
label | string | Visible row text (searched and highlighted). Defaults to id. |
hint | string | Right-aligned shortcut text, e.g. 'Ctrl+S'. Display only — no key binding is created. |
group | string | Section header the command is listed under in the empty-query view. Ungrouped commands lead, with no header. |
icon | string | Trusted inline SVG markup, injected via innerHTML. Never pass untrusted strings. |
keywords | string[] or string | Extra match terms; a string is split on whitespace. Not displayed, never highlighted. |
action | function | action(command, palette) — runs on Enter/click, after the palette has closed. |
disabled | boolean | Rendered grayed out with aria-disabled="true"; skipped by keyboard navigation, not runnable. |
The palette closes before calling
action, so the action can open a modal or move focus without
fighting the palette. With recent: true the last 5 run commands
appear in a “Recent” group at the top of the empty-query view — in memory
only, per page load; nothing is stored.
Case- and diacritic-insensitive subsequence match over
label + keywords (spaces in the query are ignored).
Scoring favors consecutive runs, word-starts (so sf finds
Save File), and matches near the start of the label; keyword hits
rank a shade below equal label hits and are never highlighted. Results are
sorted by score (registration order breaks ties) and capped at
maxResults. With an empty query all commands are shown, grouped
in first-registration order.
Every key of CommandPalette.defaults, plus the
constructor-only commands key. Options are shallow-copied over
the defaults (labels is merged one level deep); keys set to
undefined are ignored. Mutate
CommandPalette.defaults before the first static call to
configure the default palette.
| Name | Type | Default | Description |
|---|---|---|---|
commands | object | object[] | undefined |
Initial commands, passed straight to register() during construction. Constructor-only — not a key of defaults. |
hotkey | string | false | 'mod+k' |
Global toggle binding, bound on window. 'mod' = Cmd on macOS, Ctrl everywhere else. false (also null/'') = no global binding; open programmatically. See hotkey syntax below. |
placeholder | string | 'Type a command…' |
Placeholder text of the search input. |
maxResults | number | 12 |
Cap on filtered results (a non-numeric value falls back to 12). An empty query shows all commands, uncapped. |
emptyText | string | 'No matching commands' |
Message shown when nothing matches the query. |
recent | boolean | false |
true = show a session-only “Recent” group (last 5 run commands) at the top of the empty-query view. In memory only — no storage. |
styles | boolean | true |
false = headless: no CSS is ever injected; you style the .vcmd-* markup yourself (see Theming). |
theme | string | 'auto' |
'auto' | 'light' | 'dark'. Auto follows <html data-theme> / data-bs-theme / .dark/.light class, then prefers-color-scheme, re-resolved live. |
labels | object | see below | UI / ARIA strings, merged key-by-key with the defaults — override any subset (e.g. for localization). |
onOpen | function | null | null |
fn(palette) — called after the palette opens. See Events & callbacks. |
onClose | function | null | null |
fn(palette) — called when the palette starts closing. |
onRun | function | null | null |
fn(command, palette) — called after a command runs (after its action). |
labels keys| Key | Default | Used for |
|---|---|---|
title | 'Command palette' | aria-label of the dialog panel. |
search | 'Search commands' | aria-label of the search input. |
commands | 'Commands' | aria-label of the results listbox. |
recent | 'Recent' | Header of the recent-commands group. |
navigate | 'navigate' | Footer legend next to ↑↓. |
run | 'run' | Footer legend next to ↵. |
close | 'close' | Footer legend next to esc. |
All methods return the instance, so calls chain. Readable instance
properties: isOpen (boolean) and commands (the
normalized command array).
| Method | Description |
|---|---|
register(command | commands[]) |
Append command(s). A command whose id is already registered replaces the old one in place (order stays stable). Re-renders live if the palette is open. |
unregister(id) |
Remove the command with that id (and drop it from the recent list). Re-renders live if open. |
open() |
Open the palette: injects styles (unless styles: false), builds the DOM on first use, clears the query, focuses the input, then calls onOpen. No-op if already open (or without a DOM). |
close() |
Close the palette and return focus to the element focused before opening; calls onClose. No-op if already closed. |
toggle() |
open() if closed, close() if open. |
destroy() |
Unbind the global hotkey, remove the overlay from the DOM, and drop the instance from the theme watcher. |
CommandPalette.register / unregister /
open / close / toggle proxy a lazily
created default instance — nothing is built or bound until the first call —
and return CommandPalette for chaining.
| Static | Description |
|---|---|
CommandPalette.register(cmd | cmds[]) |
Register on the default palette (creating it on first call). |
CommandPalette.unregister(id) |
Unregister from the default palette. |
CommandPalette.open() / .close() / .toggle() |
Open / close / toggle the default palette. |
CommandPalette.autoInit(root) |
Wire every [data-command-open] element under root (default: document) to open the default palette on click. Idempotent — already-wired elements are skipped. Returns the array of newly wired elements. Runs automatically on DOM ready. |
CommandPalette.defaults |
The shared defaults object (see Options). Mutate before the default palette is created to configure the zero-setup surface. |
CommandPalette.salt |
CSS isolation token, default 'vc1'. Set your own token or false (no salting) before the first open. See Theming. |
CommandPalette.css |
The full embedded stylesheet as a string, rendered with the current salt (a live getter where supported). Also shipped as dist/command.css. |
CommandPalette.version |
Version string, '1.0.0'. |
displayName, rootClass, themeVars, varScopes |
Convergence contract for the optional VC family core ('CommandPalette', 'vcmd', the accent/radius/font variable map, and the scopes VC.config() writes to). Informational — not needed in normal use. |
The palette dispatches no DOM events — all notification is
via option callbacks and the per-command action:
| Callback | Signature | When |
|---|---|---|
onOpen | fn(palette) |
After the palette opens and the input is focused. |
onClose | fn(palette) |
When closing begins (focus has already been restored; the exit animation may still be running). |
onRun | fn(command, palette) |
After a command is run via Enter or click — after the palette closes and after the command's own action. |
command.action | action(command, palette) |
The command's own handler. Runs after the palette closes, before onRun. |
In all callbacks, command is the
normalized command object ({ id, label, hint, group, icon,
keywords[], action, disabled }) and palette is the
instance the command ran on.
There is exactly one declarative hook: any element with a
data-command-open attribute opens the default palette on
click. Wiring happens automatically on DOMContentLoaded (or
immediately if the script loads after DOM ready); for content added later,
call CommandPalette.autoInit(root) — elements are only wired
once. Commands themselves cannot be declared via data-*
attributes; registration is JavaScript-only.
<button type="button" data-command-open>Open command palette</button>
<script>
// after injecting new markup:
CommandPalette.autoInit(document.getElementById('late-content'))
</script>
Auto light/dark with the family's resolution order:
<html data-theme> / data-bs-theme /
.dark/.light class → prefers-color-scheme,
re-resolved live (via the VC core's watcher when loaded, otherwise a private
MutationObserver + media-query listener). Pin it with
CommandPalette.defaults.theme = 'dark'. All colors are CSS custom
properties on the .vcmd root:
| Property | Light default | Dark default | Purpose |
|---|---|---|---|
--vcmd-accent | #5b5bd6 | #7b7bea |
Match-highlight marks, active icon, focus rings. |
--vcmd-bg | #ffffff | #1b1d24 |
Panel background. |
--vcmd-surface | #f2f2f5 | #272a33 |
Active row background, footer key caps. |
--vcmd-text | #1c1d21 | #e9eaf0 |
Primary text. |
--vcmd-muted | #72747e | #989aa6 |
Placeholder, hints, group headers, icons, footer. |
--vcmd-faint | #e7e7ec | #31343f |
Borders and separators. |
--vcmd-shadow | 0 24px 64px rgba(24,25,32,.22), 0 4px 16px rgba(24,25,32,.1) |
0 24px 64px rgba(0,0,0,.55), 0 4px 16px rgba(0,0,0,.4) |
Panel drop shadow. |
--vcmd-backdrop | rgba(24,25,32,.42) | rgba(0,0,0,.55) |
Full-screen backdrop color. |
--vcmd-radius | 14px | — | Panel corner radius. |
--vcmd-font | system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif | — | Font family for the whole palette. |
.vcmd { --vcmd-accent: #b45309; --vcmd-radius: 10px; } /* your overrides */
With the VC core loaded, VC.config({ accent: '#b45309' })
themes the palette and every other family component in one call.
CSS isolation: the overlay renders as
class="vcmd vc1" and all structural rules ship salted
(.vcmd.vc1 .vcmd-option { … }) so host-page design systems can't
override the palette — while the --vcmd-* variable overrides
above keep working (variable definitions are deliberately unsalted).
Custom token: CommandPalette.salt = 'acme' before the first open;
disable with CommandPalette.salt = false.
Headless: set
CommandPalette.defaults.styles = false (or per instance
styles: false) and no CSS is ever injected. You keep the full
behavior — fuzzy matching, keyboard model, ARIA, recent group — and style the
markup contract below entirely from your own CSS. The stock stylesheet is
available as a starting point via CommandPalette.css (string) or
dist/command.css (file).
.vcmd[data-theme="dark"].vcmd-open ← .vcmd-out while leaving
.vcmd-backdrop
.vcmd-panel ← role=dialog
.vcmd-search
.vcmd-glass ← magnifier SVG
.vcmd-input ← role=combobox
.vcmd-list ← role=listbox
.vcmd-group ← section header (role=presentation)
.vcmd-option.is-active ← role=option; .is-disabled
.vcmd-icon ← only when `icon` given
.vcmd-label ← .vcmd-mark spans wrap matches
.vcmd-hint ← only when `hint` given
.vcmd-empty ← only when nothing matches
.vcmd-footer ← ↑↓ navigate · ↵ run · esc close
| Key | Action |
|---|---|
| Ctrl/⌘+K (configurable) | Toggle the palette. Bound on window; deliberately fires even while an input, textarea, or contenteditable has focus — it is the palette's only global binding, so ordinary typing is never intercepted. |
| ↑ / ↓ | Move the active command. Wraps in both directions, skips disabled commands, scrolls the row into view. |
| Enter | Run the active command (ignored mid-IME-composition). The palette closes first, then action, then onRun. |
| Esc | Clears the query first if non-empty; a second Esc closes the palette. |
| Tab | Trapped — focus stays in the search input (combobox pattern). |
Simple modifier+…+key strings: 'mod+k' (default),
'ctrl+shift+p', 'alt+space'. mod means
Cmd on macOS, Ctrl everywhere else; other modifier aliases
are ctrl/control, shift,
alt/option, and
meta/cmd/command/win.
Set hotkey: false to disable the global binding.
role="combobox" with aria-expanded,
aria-controls, aria-autocomplete="list", and
aria-activedescendant pointing at the active
role="option" row; group headers and the empty state are
presentational.role="dialog" aria-modal="true" labeled by
labels.title; the footer key legend is
aria-hidden (the same keys are conveyed by the combobox
semantics). Disabled options carry aria-disabled="true".prefers-reduced-motion: reduce disables all transitions).styles: false injects nothing — fuzzy matching, the
keyboard model, ARIA and the stable .vcmd-* class hooks all remain; you bring
your own CSS. The demos above have already injected the kit stylesheet into this page, so
a headless instance here would still match those rules — each demo below therefore runs
inside its own <iframe>, a genuinely clean document.
Raw browser rendering, zero CSS — open it: fuzzy search, ↑↓, Enter, Esc and the match highlighting all still work.
var pal = new CommandPalette({
styles: false, // no CSS injected
commands: [ /* … */ ]
})
openBtn.onclick = function () { pal.open() }
The same hooks mapped to a dark launcher look with Tailwind @apply.
Loads cdn.tailwindcss.com only when you ask.
.vcmd { @apply fixed inset-0 z-50 flex items-start
justify-center p-4 pt-[10vh] font-sans; }
.vcmd-backdrop { @apply absolute inset-0
bg-slate-950/70 backdrop-blur-sm; }
.vcmd-panel { @apply relative z-10 w-full max-w-lg
overflow-hidden rounded-2xl border border-white/10
bg-slate-900 text-slate-200 shadow-2xl; }
.vcmd-search { @apply flex items-center gap-3
border-b border-white/10 px-4 py-3; }
.vcmd-input { @apply w-full border-0 bg-transparent
text-base text-slate-100 outline-none; }
.vcmd-list { @apply max-h-72 overflow-y-auto p-2; }
.vcmd-group { @apply px-3 pt-3 pb-1 text-[11px]
font-semibold uppercase tracking-widest text-slate-500; }
.vcmd-option { @apply flex cursor-pointer items-center
gap-3 rounded-lg px-3 py-2.5 text-sm; }
.vcmd-option.is-active { @apply bg-amber-400/10 text-amber-300; }
.vcmd-option.is-disabled { @apply opacity-40 cursor-not-allowed; }
.vcmd-mark { @apply rounded-sm bg-amber-400/20 text-amber-200; }
.vcmd-hint { @apply text-xs text-slate-500; }
.vcmd-empty { @apply px-3 py-10 text-center text-slate-500; }
.vcmd-footer { @apply flex gap-4 border-t border-white/10
px-4 py-2 text-[11px] text-slate-500; }
.vcmd-key { @apply rounded border border-white/15 bg-white/5
px-1.5 py-0.5 font-mono text-[10px] text-slate-400; }