Command Palette
stimeo--command-palette
An accessible command launcher. Combines Dialog + Combobox with virtual focus, roving keys, focus traps, and inline filtering.
The stimeo--command-palette controller implements a highly interactive launcher dashboard. It conforms to WAI-ARIA Dialog and Combobox (listbox with autocomplete) patterns. A global shortcut (mod+k or Cmd/Ctrl+K) toggles visibility, trapping focus in the input. Search results filter options case-insensitively, and navigation is completely keyboard-operable using ArrowUp/ArrowDown, utilizing `aria-activedescendant` for roving virtual focus.
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="command-palette-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 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 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__trigger {
padding: 0.5rem 1rem;
font-size: 1rem;
cursor: pointer;
border: 1px solid var(--border-interactive);
border-radius: 0.375rem;
background: var(--surface-card);
transition: border-color 0.15s ease;
}
.command-palette-demo__trigger:hover {
border-color: var(--accent);
}
.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;
}
.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);
}
/* 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 | Applied to the active/focused option for CSS styling and assistive technologies. |
data-disabled="true" |
Command option | Applied to unselectable options (e.g. headers or separators) to prevent focus. |