Command Palette
stimeo--command-palette
A command launcher you call up from the keyboard, filtering and moving without leaving it.
A command launcher you call up with Cmd/Ctrl+K. It combines a dialog with a list of choices under a text field, following the WAI-ARIA Dialog and Combobox patterns. Opening puts focus in the field and keeps it there. Typing filters the choices, ignoring case. The arrow keys move through them while focus stays in the field, so you can keep typing. Screen readers are told which choice is current.
Or press Cmd+K / Ctrl+K to open it from anywhere.
- Popular Commands
- View My Profile Go P
- Account Settings Go S
- System Administration
- Toggle Dark Mode
- Toggle Light Mode
Keyboard
| Key | Action |
|---|---|
| Cmd+K / Ctrl+K | Toggle the visibility of the command palette from anywhere. |
| ↓ / ↑ | Move virtual focus between command options (roving activedescendant). |
| Enter | Select the virtually focused command option. |
| Esc | Close the command palette and restore focus to the previous active element. |
| Tab / Shift+Tab | Trapped within the search input and close button (focus trap). |
<%# Markup for the command_palette demo.
The single source used for both the live render and the copy-paste code.
stimeo--command-palette provides the global shortcut (Cmd+K) / arrow navigation /
focus trap. The controller registers the global keydown itself in connect(), so no
keydown wiring is needed here. %>
<div
class="command-palette-demo"
data-controller="stimeo--command-palette">
<button
type="button"
class="demo-trigger"
data-action="click->stimeo--command-palette#open">
<%= t("components.command_palette.demo.open_button") %>
</button>
<p class="command-palette-demo__help">
<%= t("components.command_palette.demo.help_text_html") %>
</p>
<%# Overlay backdrop. Closes only on a click on the backdrop itself
(closeOnBackdrop ignores inner clicks). %>
<div
class="command-palette"
role="dialog"
aria-modal="true"
aria-label="<%= t("components.command_palette.demo.popular_title") %>"
data-stimeo--command-palette-target="dialog"
data-action="click->stimeo--command-palette#closeOnBackdrop"
hidden>
<%# The palette body container. %>
<div class="command-palette__panel">
<%# Control area (Combobox structure). role/aria-* go on the real input
(the controller syncs them). %>
<div class="command-palette__search-wrapper">
<svg class="command-palette__search-icon" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2">
<circle cx="11" cy="11" r="8"/>
<line x1="21" y1="21" x2="16.65" y2="16.65"/>
</svg>
<input
type="text"
placeholder="<%= t("components.command_palette.demo.placeholder") %>"
class="command-palette__input"
data-stimeo--command-palette-target="input"
data-action="input->stimeo--command-palette#filter
keydown->stimeo--command-palette#onKeydown"
role="combobox"
aria-expanded="false"
aria-haspopup="listbox"
aria-controls="playground-command-list"
aria-autocomplete="list" />
<button
type="button"
class="command-palette__close-btn"
data-action="click->stimeo--command-palette#close"
aria-label="<%= t("components.command_palette.demo.close_label") %>">
<kbd class="command-palette-demo__kbd font-normal text-xs">ESC</kbd>
</button>
</div>
<%# Listbox display area. %>
<ul
id="playground-command-list"
role="listbox"
aria-label="<%= t("components.command_palette.demo.popular_title") %>"
class="command-palette__listbox"
data-stimeo--command-palette-target="list">
<%# Section heading (data-disabled="true" makes it non-selectable). %>
<li id="cmd-popular-heading" role="option"
data-stimeo--command-palette-target="option"
data-disabled="true" class="command-palette__group-title">
<%= t("components.command_palette.demo.popular_title") %>
</li>
<li
id="cmd-profile"
role="option"
data-stimeo--command-palette-target="option"
data-action="click->stimeo--command-palette#selectByClick"
data-alert="<%= t("components.command_palette.demo.profile_alert") %>"
class="command-palette__option">
<svg class="command-palette__option-icon" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/>
<circle cx="12" cy="7" r="4"/>
</svg>
<span class="command-palette__option-text"><%= t(
"components.command_palette.demo.profile_label"
) %></span>
<span class="command-palette__option-shortcut">Go P</span>
</li>
<li
id="cmd-settings"
role="option"
data-stimeo--command-palette-target="option"
data-action="click->stimeo--command-palette#selectByClick"
data-alert="<%= t("components.command_palette.demo.settings_alert") %>"
class="command-palette__option">
<svg class="command-palette__option-icon" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="3"/>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65
1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0
0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65
1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0
4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0
0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1
1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0
0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0
0-1.51 1z"/>
</svg>
<span class="command-palette__option-text"><%= t(
"components.command_palette.demo.settings_label"
) %></span>
<span class="command-palette__option-shortcut">Go S</span>
</li>
<li id="cmd-admin-heading" role="option"
data-stimeo--command-palette-target="option"
data-disabled="true" class="command-palette__group-title">
<%= t("components.command_palette.demo.admin_title") %>
</li>
<li
id="cmd-theme-dark"
role="option"
data-stimeo--command-palette-target="option"
data-action="click->stimeo--command-palette#selectByClick"
data-alert="<%= t("components.command_palette.demo.dark_alert") %>"
class="command-palette__option">
<svg class="command-palette__option-icon" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
</svg>
<span class="command-palette__option-text"><%= t(
"components.command_palette.demo.dark_label"
) %></span>
</li>
<li
id="cmd-theme-light"
role="option"
data-stimeo--command-palette-target="option"
data-action="click->stimeo--command-palette#selectByClick"
data-alert="<%= t("components.command_palette.demo.light_alert") %>"
class="command-palette__option">
<svg class="command-palette__option-icon" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="5"/>
<line x1="12" y1="1" x2="12" y2="3"/>
<line x1="12" y1="21" x2="12" y2="23"/>
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/>
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/>
<line x1="1" y1="12" x2="3" y2="12"/>
<line x1="21" y1="12" x2="23" y2="12"/>
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/>
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>
</svg>
<span class="command-palette__option-text"><%= t(
"components.command_palette.demo.light_label"
) %></span>
</li>
</ul>
</div>
</div>
</div>
/*
* Presentation-only styles for the command_palette demo.
* Toned-down styling that blends with the wireframe look (plain white background)
* of the other components (Dialog, Combobox, etc.).
*/
.command-palette-demo {
margin: 1rem 0;
}
.command-palette-demo__help {
margin: 0.5rem 0 0;
font-size: 0.875rem;
color: var(--muted);
}
/* Modal backdrop - identical to dialog__backdrop. */
.command-palette {
position: fixed;
inset: 0;
display: flex;
align-items: flex-start;
justify-content: center;
padding: 4rem 1rem;
background: rgba(0, 0, 0, 0.5);
z-index: 200;
animation: cp-fade-in 0.15s ease-out;
}
.command-palette[hidden] {
display: none !important;
}
/* Palette body - plain white background and shadow, matching dialog__panel. */
.command-palette__panel {
width: min(36rem, 100%);
background: var(--bg);
border: 1px solid var(--border);
border-radius: 0.5rem;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.25);
overflow: hidden;
animation: cp-slide-down 0.15s ease-out;
}
/* Search input area. */
.command-palette__search-wrapper {
display: flex;
align-items: center;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--border);
gap: 0.75rem;
}
/* The input drops its own outline (a ring inside the panel would trace the borderless
field), so the row it sits in carries the accent instead — one line, laid over the
row's own bottom edge rather than beside it. */
.command-palette__search-wrapper:focus-within {
box-shadow: inset 0 -2px 0 var(--accent);
}
.command-palette__search-icon {
width: 1.15rem;
height: 1.15rem;
color: var(--muted);
flex-shrink: 0;
}
.command-palette__input {
width: 100%;
border: none;
background: transparent;
color: var(--fg);
font-size: 1rem;
font-family: inherit;
outline: none;
}
.command-palette__input::placeholder {
color: var(--muted);
}
.command-palette__close-btn {
background: transparent;
border: none;
cursor: pointer;
padding: 0.25rem 0.5rem;
display: flex;
align-items: center;
border: 1px solid var(--border);
border-radius: 0.25rem;
font-size: 0.75rem;
color: var(--muted);
background: var(--surface-subtle);
}
.command-palette__close-btn:hover {
border-color: var(--border-interactive);
}
/* Keyboard focus takes the accent ring, like every other control on this page. The
resting border keeps its own colour so the two never stack. */
.command-palette__close-btn:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
/* List area. */
.command-palette__listbox {
max-height: 20rem;
overflow-y: auto;
margin: 0;
padding: 0.25rem;
list-style: none;
}
/* Section title. */
.command-palette__group-title {
padding: 0.5rem 0.75rem 0.25rem;
font-size: 0.75rem;
font-weight: 600;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.05em;
user-select: none;
}
/* Option items - consistent with the items in dropdown.css and combobox.css. */
.command-palette__option {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.5rem 0.75rem;
border-radius: 0.25rem;
cursor: pointer;
user-select: none;
font-size: 0.95rem;
color: var(--fg);
}
.command-palette__option-icon {
width: 1rem;
height: 1rem;
color: var(--muted);
flex-shrink: 0;
}
.command-palette__option-text {
flex-grow: 1;
}
.command-palette__option-shortcut {
font-size: 0.75rem;
color: var(--muted);
}
/* Roving virtual focus status: aria-selected="true" - consistent with combobox. */
.command-palette__option[aria-selected="true"] {
background: var(--accent);
color: var(--white);
}
.command-palette__option[aria-selected="true"] .command-palette__option-icon {
color: var(--white);
}
.command-palette__option[aria-selected="true"] .command-palette__option-shortcut {
color: rgba(255, 255, 255, 0.8);
}
/* Animations. */
@keyframes cp-fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes cp-slide-down {
from {
opacity: 0;
transform: translateY(-4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
// Consumer-side example: handle the select event and run the chosen command (here, an alert).
// On selection the controller closes the palette and dispatches stimeo--command-palette:select.
document
.querySelector('.command-palette-demo')
.addEventListener('stimeo--command-palette:select', function (e) {
const message = e.detail.option && e.detail.option.dataset.alert;
if (message) window.alert(message);
});
These demo styles use shared design tokens (light + dark). Copy the shared styles too, then toggle data-theme on your root element for dark mode.
The data-* attributes you add to your own HTML to wire this component. Put the data-controller below on a root element, then place its targets / values / actions inside that element.
On the root element
data-controller="stimeo--command-palette"
Targets
| Name | Description | Attribute |
|---|---|---|
dialog
required
|
The role="dialog" aria-modal container shown/hidden via hidden; clicking it (the backdrop) closes the palette. |
data-stimeo--command-palette-target="dialog" |
input
required
|
The role="combobox" search field driving the filter; carries aria-expanded and aria-activedescendant. |
data-stimeo--command-palette-target="input" |
list
required
|
The role="listbox" container holding the command options. |
data-stimeo--command-palette-target="list" |
option
|
A role="option" command; filtered, navigable via virtual focus, and selectable (disabled ones excluded). |
data-stimeo--command-palette-target="option" |
empty
|
The empty-state element shown when no selectable option matches the query. | data-stimeo--command-palette-target="empty" |
Values
| Name | Description | Attribute |
|---|---|---|
hotkey
|
The global hotkey that toggles the palette (default mod+k; mod matches Cmd or Ctrl). |
data-stimeo--command-palette-hotkey-value |
open
|
Whether the palette is open; reflects/initializes the open state (default false). | data-stimeo--command-palette-open-value |
Actions
| Name | Description | Action |
|---|---|---|
close
|
Closes the palette and restores focus to the opener. | stimeo--command-palette#close |
closeOnBackdrop
|
Closes only when the click landed on the dialog backdrop itself, ignoring inner clicks. | stimeo--command-palette#closeOnBackdrop |
filter
|
Filters options in-memory against the input value, toggles the empty state, and resets active option. | stimeo--command-palette#filter |
onKeydown
|
Handles combobox navigation on the input: Arrow keys, Home/End, and Enter to select. | stimeo--command-palette#onKeydown |
open
|
Opens the palette, traps focus, focuses the input, and resets the filter. | stimeo--command-palette#open |
selectByClick
|
Selects the clicked option (ignoring disabled ones), dispatching select and closing. |
stimeo--command-palette#selectByClick |
toggle
|
Toggles the palette open/closed. | stimeo--command-palette#toggle |
Events
| Name | Description | Event |
|---|---|---|
select
|
Fires when an option is chosen; detail carries the option's value and the option element. | stimeo--command-palette:select |
State hooks
The library only manages these ARIA/data attributes and custom properties. Your CSS reads them to render the look — selectors like [aria-selected], [aria-expanded], or var(--stimeo--…) hook into this state.
| Hook | Target | Meaning |
|---|---|---|
hidden |
Dialog overlay | Present when closed, removed when open — controls overlay visibility. |
aria-activedescendant="[option-id]" |
Combobox input | Points to the ID of the currently active/focused option for screen readers. |
aria-selected="true" |
Command option | Marks the command the keyboard is currently on — where you are, not what you ran. It comes off when the palette closes. The controller owns it; do not author it. |
data-disabled="true" |
Command option | Applied to unselectable options (e.g. headers or separators) to prevent focus. |