ui-007

Cmd+K Command Palette

£10 or the whole Vault, £99

A Cmd+K command palette: fuzzy filtering, nested pages that slide in with a breadcrumb, and a panel height that morphs as results change.

intermediate
5 more details
micro-interactioncommand-palettefuzzy-searchkeyboard-navigationmorphing

This element reads better at your own screen size than in the frame below:

Updated

About this component

A Cmd+K command palette you mount with commands as data: fuzzy filtering as you type, arrow-key selection, and nested pages, so a command like Change theme opens Light, Dark and System with a directional slide and a breadcrumb chip in the search field. The panel's height tweens between measured sizes as results grow and shrink, so it never jumps. CommandPalette.mount(root, { commands, hotkey }) returns open, close and destroy, fires palette:run, palette:page, palette:open and palette:close events, and supports several instances per page. Vanilla JavaScript and GSAP, no framework, in light and dark themes.

Cmd+K Command Palette - UI component preview

What's included

10 items
  • CommandPalette.mount(root, { commands, hotkey }) with open(), close(), destroy() and several instances per page
  • Nested pages: a command with children slides in a sub-page with a breadcrumb chip; Backspace, Escape or the chip goes back
  • The panel height morphs between measured sizes while filtering and between pages, never a Flip scale
  • Cmd+K / Ctrl+K toggles from anywhere; Escape goes back a page, then closes
  • Subsequence fuzzy filter over labels, keywords and groups, with an empty state
  • palette:run, palette:page, palette:open and palette:close events, plus a run() callback per command
  • Focus returns to the opener, Tab is trapped while open, and the page behind does not scroll
  • dialog, combobox, listbox and option ARIA with aria-activedescendant and announced page changes
  • destroy() reverts every tween, listener and attribute, so mount and remount is clean
  • Two themes, light and dark, on one set of custom properties

Perfect for

4 use cases
  • Global search and quick actions in web apps and admin portals
  • Settings and assignment flows that need a second step (pick a theme, a person, a project)
  • Developer tools and dashboards with many actions
  • Documentation sites with keyboard navigation

How it works

4 sections

Opening and closing

The hotkey (Cmd+K / Ctrl+K by default) is bound on the document, so it toggles the palette wherever focus is. The window scales up from 0.94 as the backdrop fades in; closing reverses it and hides the overlay on complete, so no invisible layer is left covering the page. Focus moves to the input on open and back to the opener on close, and both the document element and the body are scroll-locked while it is open.

Nested pages and the height morph

A command with children opens a page. The old rows are copied into an inert layer that slides 24px left and fades while the real list, already holding the new page, slides in from the right, and a breadcrumb chip appears in the search field. Every change to the rows measures the window before and after and tweens height between the two, so filtering and page changes never jump. Backspace on an empty field or Escape runs the same slide in reverse.

Running a command

Enter or a click flashes the row using colours read from the active theme, closes the palette, then dispatches a cancelable palette:run event and calls the command's run() callback. Both happen after the scroll lock is released, so a handler is free to navigate or scroll.

Lifecycle

Every tween lives in one gsap.context scoped to the root and every listener in one registry, and the attributes the script writes are recorded at mount. destroy() reverts the context, removes the listeners and restores the markup, so a framework can mount and unmount it freely.

Difficulty Intermediate
Includes HTML + JS + CSS source, documentation, AI setup prompt, lifetime updates

Lighthouse, as measured

Google Lighthouse on this UI element's demo, from the latest scan. A measurement of the demo as shipped, not a promise for your page.

Accessibility
100
Best practices
100

No performance score, on purpose. That figure depends on how you deploy: your server's compression and caching, your CDN, the connection and the device doing the test, none of which the code controls. The same page can score very differently on two consecutive runs, so measure it where it will live.

Paid UI element

Purchase to unlock the code.

Buying Cmd+K Command Palette opens the HTML, CSS and JavaScript source, the full documentation, an AI setup prompt for your editor, and every update we ship to it. Standard license: unlimited personal and commercial projects. Every paid product is tested for desktop and mobile layouts, reduced-motion handling, animation cleanup and keyboard controls wherever there is something to operate.

£10 Standard license, unlimited projects

Browse UI elements

Or the whole library: the Vault, £99, one payment.

Documentation

What's Included

  • index.html: the demo page and the markup to copy

  • assets/style.css: component styles, plain CSS custom properties for colours

  • assets/script.js: readable, commented source; one global, window.CommandPalette

  • CommandPalette.mount(root, { commands, hotkey }) returns open(), close(), destroy() and a few helpers; mount as many palettes as you like

  • Commands are data: { id, label, group, shortcut, keywords, icon, run(), children }

  • Nested pages. A command with children opens a sub-page ("Change theme..." then Light / Dark / System). The rows slide left as the page enters and a breadcrumb chip appears in the search field; Backspace on an empty field, Escape, or a click on the chip goes back with the reverse slide

  • Height morph. The panel tweens between measured heights as the results grow and shrink while you type and between pages, so it never jumps

  • Cmd+K / Ctrl+K toggles from anywhere (configurable); Escape goes back a page, and on the first page closes; a backdrop click closes

  • Subsequence fuzzy filter over each command's label, keywords and group, with an empty state

  • ArrowUp/Down wrap, Home/End jump; Enter or a click flashes the row and runs it

  • palette:run, palette:page, palette:open, palette:close events

  • Focus returns to whatever opened the palette, Tab is trapped while it is open, and the page behind does not scroll

  • dialog + combobox / listbox / option ARIA with aria-activedescendant, and a polite status line announcing pages and result counts

  • destroy() removes every listener, reverts every tween and inline style, and restores the markup it found

Quick Start

1. Add to your <head>:

Code snippet omitted: it ships with the download.

2. Add before the closing </body> tag:

Code snippet omitted: it ships with the download.

3. Mount it with your commands.

Code snippet omitted: it ships with the download.

An empty root gets the default window built inside it. To control the markup yourself, put the .palette-overlay markup from index.html inside the root (or pass the .palette-overlay itself as the root) and the script uses yours. Rows are always rendered from commands.

Without an overlay (a bare .palette-window), the palette is simply always on screen: open() and close() do nothing and commands run in place, which is what an inline command menu wants.

The demo toolbar, the launcher's surroundings and the .stage-* ground are demo furniture, not part of what you bought. In the demo only the "Change theme" page changes anything visible, because there is no application behind the palette; the other commands announce what they ran.

API

CommandPalette.mount(root, options)

root is an element or a selector. Mounting the same root twice returns the existing instance (and applies options.commands if given).

Option Default What it does
commands read from .palette-item markup The first page's commands (see below)
hotkey 'mod+k' Toggles the palette from anywhere. mod is Cmd or Ctrl; also ctrl, meta, shift, alt, e.g. 'mod+shift+p'. false for none
trigger none The launcher button (element or selector). Gets aria-haspopup, aria-expanded, aria-controls, and opens on click
emptyText 'No command matches that.' The empty state, created when the markup has no .palette-empty. false for none

A command

Field Type What it is
id string Unique within its page; lands on the row as data-action
label string The row text. A trailing … is dropped for the breadcrumb chip
group string The badge on the right ("Create", "Navigate"); also matched by the filter
shortcut string or string[] Keys shown as <kbd> chips. Display only: bind them yourself
keywords string or string[] Extra words the filter matches (aliases, emails)
icon string An SVG sprite symbol id, e.g. 'i-settings' for <use href="#i-settings">
run(ctx) function Called after the palette has closed. ctx is { command, path, palette }
children array or function Makes the command open a sub-page instead of running. A function is called each time the page opens, so the page can reflect current state
placeholder string The search placeholder on this command's sub-page
pageLabel string The breadcrumb text, if it should differ from label
disabled boolean The row shows but does not run

The instance

Member What it does
open(pageId?) Opens the palette. With a command id, opens straight onto that command's page (e.g. an "Assign" button opening 'assign-to')
close() Closes, releases the scroll lock, returns focus
toggle() Opens or closes
back() Goes back one page; returns false on the first page
isOpen() Current state
path() The ids of the open pages, e.g. ['change-theme']
setCommands(commands) Replaces the commands. The open page is kept if it still exists, and the height morphs to the new list
destroy() Removes listeners, reverts tweens and inline styles (gsap.context().revert()), releases the scroll lock, restores the original attributes and rows, removes what it created
root The root element

CommandPalette.get(el) returns the instance mounted on (or inside) an element, or null.

Events

All bubble, so listen on the root or on document.

Event Fired on detail
palette:run the chosen row { command, id, label, path, item, index }. Cancelable: preventDefault() skips run()
palette:page root { command, path, depth } after entering or leaving a page (command is null on the first page)
palette:open root { path }
palette:close root {}, after focus has returned
palette:destroy root {}

palette:run and run() both fire after the palette has closed and released the scroll lock, so a handler is free to navigate or scroll straight away.

Declarative mounting

<div data-command-palette> around the palette markup mounts itself on load, with commands read from its .palette-item rows (data-action becomes the id, data-label the keywords, the badge the group). data-trigger="#selector" and data-hotkey="mod+k" set those options. Listen for palette:run to act.

Upgrading from the earlier version

Markup from the earlier, id-based version (#paletteOverlay, #paletteWindow, #paletteTrigger, .palette-item rows) still mounts itself on load, and palette:run still carries detail.item with its data-action, so existing handlers keep working. One breaking change: the window.openPalette and window.closePalette globals are gone. Use CommandPalette.get('#paletteOverlay').open() instead.

Keyboard & Accessibility

Key What it does
⌘K / Ctrl+K Opens the palette from anywhere on the page; pressing it again closes
↑ / ↓ Moves the selection, wrapping within the visible rows
Home / End First / last row, when the field is empty
Enter Opens the row's page, or flashes the row, closes, and runs it
Backspace On an empty field in a sub-page, goes back one page
Escape Goes back one page; on the first page closes, and focus returns to what opened it
Tab / Shift+Tab Cycles within the window (the field and the breadcrumb chip)
  • Roles: role="dialog" with aria-modal="true" on the window; role="combobox" with aria-controls, aria-autocomplete="list" and aria-activedescendant on the input; role="listbox" on the results (its aria-label names the current page); role="option" with aria-selected on every row, and aria-haspopup="listbox" on a row that opens a page.
  • The breadcrumb chip is a real button labelled "Back from ", so touch users, where Backspace on an empty field is unreliable, still have a way back.
  • A visually hidden role="status" line announces the page on entry ("Assign to, 6 options") and the result count while filtering.
  • Focus moves to the input on open, and back on close to the element that was focused before (the launcher when the hotkey was pressed from nowhere).
  • Scroll lock is applied to <html> and <body> while the palette is open, counted across instances, and the scroll position is restored on close.
  • Pointer selection follows pointer movement, so a page sliding in under a resting pointer never steals the keyboard selection.
  • prefers-reduced-motion: every state change happens, without tweening; the page swap and height change are instant.

How It Works

Opening and closing. The window scales up from 0.94 while the backdrop fades in; closing runs the reverse and sets hidden on the overlay in onComplete, so an invisible layer is never left covering the page. Every open starts on the first page (or the page passed to open()) with a clear field.

Height morph. Any change to the rows (a keystroke, a page change, setCommands) goes through one function: read the window's current rendered height, clear any inline height, change the DOM, read the natural height, and tween height from the first to the second, clearing it on complete. The heights are measured pixels, not a Flip scale, so text is never squashed; a change that lands mid-tween starts from the height on screen at that frame, so fast typing never jumps.

Nested pages. Entering a page copies the current list into an inert, aria-hidden layer on top, renders the new page into the real list straight away, then slides the copy out 24px to the left while the real list comes in from 24px on the right and the window morphs to its new height. Going back mirrors the direction. Because the real list is already the new page on the first frame, a fast Enter or arrow key always acts on the rows you can see arriving, never on rows that are leaving. Going back selects the row that opened the page.

Running a command. Enter (or a click) reads --flash-bg / --flash-fg from the selected row with getComputedStyle at that moment, flashes to them and settles back to --selected-bg / --selected-fg, then closes, dispatches palette:run and calls run(). Because the colours are read from the row, the flash follows the active theme with no script change.

Lifecycle. Every tween is created inside one gsap.context scoped to the root, every listener goes through one registry, and the attributes of every element the script writes to are recorded at mount. destroy() reverts the context, removes the listeners, and puts those attributes and the original rows back, so mounting, destroying and mounting again (a React effect in strict mode does exactly that) leaves no duplicate listeners or stray styles.

Customisation

  • The dim behind the palette is --backdrop and --backdrop-blur.
  • Row height is min-height on .palette-item (36px); the results list caps at max-height on .palette-results, and the height morph respects it.
  • The breadcrumb chip is .palette-crumb, coloured by --crumb-fg, --kbd-bg and --kbd-border.
  • An element with data-palette-esc-hint in the footer reads "Esc to go back" on a sub-page and returns to its own text on the first page.
  • Slide distance and durations are in transition() and morph() in assets/script.js (24px, 160-240ms, power3.out).

Themes

The palette ships in two themes, light and dark (the default here). One design, two sets of token values. Pick one by setting the attribute:

Code snippet omitted: it ships with the download.

Nothing else changes: same markup, same script, and no theme name is ever read in JavaScript. The demo's toolbar toggle and the ?variant=light URL parameter only set that attribute.

Every colour, radius and shadow the component uses is a custom property in the body[data-variant="light"] and body[data-variant="dark"] blocks at the top of assets/style.css:

--window-bg, --window-border, --window-radius, --window-shadow, --search-bg, --search-border, --icon-fg, --placeholder, --kbd-bg, --kbd-border, --kbd-fg, --item-fg, --item-radius, --item-hover-bg, --item-icon-fg, --selected-bg, --selected-fg, --selected-icon-fg, --badge-fg, --badge-border, --selected-badge-fg, --selected-badge-border, --footer-bg, --footer-border, --footer-fg, --footer-key-fg, --empty-fg, --crumb-fg, --flash-bg, --flash-fg, --backdrop, --backdrop-blur.

Most of them resolve from a small ground scale in the same blocks (--ground, --ground-2, --raised, --hover, --ink, --ink-2, --ink-3, --line, --line-strong, --accent, --accent-ink, --radius, --radius-sm, --radius-xs, --font), so matching your own app usually means re-valuing those and nothing else. The accent is used only for the selected row, the focus ring and ::selection.

--flash-bg, --flash-fg, --selected-bg and --selected-fg are literal colours on purpose: the script reads them with getComputedStyle, and a property holding var(...) comes back unresolved.

Requirements

  • GSAP 3.12+ (core only)
  • No build step, no framework

Worked examples, the events and programmatic API, and the class reference ship with the download, alongside the full source.