Command
A searchable command palette for quick actions. Built on native <dialog> with search filtering. Open with Cmd+K.
Default
Click the button or press ⌘K to open. Use Arrow keys to navigate, Enter to select, Escape to close.
<button class="btn" data-command-trigger="cmd">Open</button>
<dialog id="cmd" class="command" role="dialog" aria-modal="true">
<div class="command-content">
<div class="command-input-wrapper">
<svg>...search...</svg>
<input class="command-input" type="text" placeholder="Type a command...">
</div>
<div class="command-list">
<div class="command-group">
<p class="command-group-heading">Suggestions</p>
<button class="command-item">Calendar</button>
</div>
</div>
</div>
</dialog>
CSS view file
Styles for the command component. Uses design tokens for colors, spacing, and radius.
@layer components {
dialog.command {
border: none; border-radius: var(--radius-xl);
background: var(--popover); color: var(--popover-foreground);
padding: 0; max-width: 32rem; width: calc(100% - 2rem);
box-shadow: var(--shadow-lg); margin: auto; position: fixed; inset: 0;
top: 15%; bottom: auto; overflow: hidden;
opacity: 0; transform: scale(0.98);
transition: opacity 150ms ease, transform 150ms ease, display 150ms allow-discrete;
&[open] { opacity: 1; transform: scale(1); }
&::backdrop { background: oklch(0 0 0 / 0); transition: all 150ms ease, display 150ms allow-discrete; }
&[open]::backdrop { background: oklch(0 0 0 / 0.45); }
}
@starting-style {
dialog.command[open] { opacity: 0; transform: scale(0.98); }
dialog.command[open]::backdrop { background: oklch(0 0 0 / 0); }
}
.command-input-wrapper {
display: flex; align-items: center; gap: 0.5rem;
padding: 0.75rem 1rem; border-bottom: 1px solid var(--border);
& svg { width: 1rem; height: 1rem; color: var(--muted-foreground); flex-shrink: 0; }
}
.command-input {
flex: 1; border: none; background: transparent;
font-size: 0.875rem; color: var(--foreground); outline: none; font-family: inherit;
&::placeholder { color: var(--muted-foreground); }
}
.command-list { max-height: 18rem; overflow-y: auto; overscroll-behavior: contain; padding: 0.25rem; }
.command-group { padding: 0.25rem 0; }
.command-group-heading { margin: 0; padding: 0.375rem 0.5rem; font-size: 0.75rem; font-weight: 500; color: var(--muted-foreground); }
.command-item {
display: flex; align-items: center; gap: 0.5rem; width: 100%;
padding: 0.5rem 0.5rem; border: none; border-radius: var(--radius-md);
background: transparent; color: var(--foreground); font-size: 0.8125rem;
font-family: inherit; text-align: left; cursor: pointer; outline: none;
&:hover, &[data-highlighted] { background-color: var(--accent); }
&:focus-visible { outline: 2px solid var(--ring); outline-offset: -2px; }
&[aria-disabled="true"] { pointer-events: none; opacity: 0.5; }
& svg { width: 1rem; height: 1rem; color: var(--muted-foreground); flex-shrink: 0; }
}
.command-separator { height: 1px; background-color: var(--border); margin: 0.25rem -0.25rem; }
.command-shortcut { margin-inline-start: auto; font-size: 0.6875rem; color: var(--muted-foreground); font-family: var(--font-mono); }
.command-empty { padding: 1.5rem; text-align: center; font-size: 0.875rem; color: var(--muted-foreground); }
/* -- Accessibility ------------------------------------------ */
@media (prefers-reduced-motion: reduce) {
dialog.command { transition: none; opacity: 1; transform: none; }
dialog.command::backdrop { transition: none; }
}
@media (prefers-contrast: more) {
dialog.command { border: 2px solid var(--border); }
.command-item {
&:hover, &[data-highlighted] { outline: 1px solid var(--foreground); }
}
}
@media (forced-colors: active) {
dialog.command { border: 2px solid CanvasText; }
.command-item {
&:hover, &[data-highlighted] { forced-color-adjust: none; background: Highlight; color: HighlightText; }
}
.command-separator { background-color: CanvasText; }
}
}
JavaScript view file
Interaction logic for the command component. Uses data attributes for wiring.
// -- Command --------------------------------------------------
// Command palette dialog with search filtering, keyboard navigation, and Cmd/Ctrl+K shortcut.
/* Cmd/Ctrl+K handler — added once at module level */
let commandKeydownAdded = false;
if (!commandKeydownAdded) {
commandKeydownAdded = true;
document.addEventListener('keydown', (e) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
const dialog = document.querySelector('dialog.command');
if (!dialog) return;
e.preventDefault();
if (dialog.open) { dialog.close(); }
else { dialog.showModal(); const input = dialog.querySelector('.command-input'); if (input) input.focus(); }
}
});
}
function getVisibleItems(list) {
return Array.from(list.querySelectorAll('.command-item:not([hidden]):not([aria-disabled="true"])'));
}
function highlightItem(list, index) {
const visible = getVisibleItems(list);
list.querySelectorAll('.command-item[data-highlighted]').forEach((el) => delete el.dataset.highlighted);
if (visible.length === 0) return -1;
const clamped = ((index % visible.length) + visible.length) % visible.length;
visible[clamped].dataset.highlighted = '';
visible[clamped].scrollIntoView({ block: 'nearest' });
return clamped;
}
function init() {
document.querySelectorAll('dialog.command:not([data-init])').forEach((dialog) => {
dialog.dataset.init = '';
const input = dialog.querySelector('.command-input');
const list = dialog.querySelector('.command-list');
const empty = dialog.querySelector('.command-empty');
if (!input || !list) return;
const items = Array.from(list.querySelectorAll('.command-item'));
let highlightIndex = -1;
const filter = (q) => {
const query = q.toLowerCase(); let hasVisible = false;
items.forEach((item) => {
const match = !query || item.textContent.toLowerCase().includes(query);
item.hidden = !match; if (match) hasVisible = true;
});
list.querySelectorAll('.command-group').forEach((g) => {
g.hidden = g.querySelectorAll('.command-item:not([hidden])').length === 0;
});
list.querySelectorAll('.command-separator').forEach((s) => {
s.hidden = !!query;
});
if (empty) empty.hidden = hasVisible;
highlightIndex = highlightItem(list, 0);
};
input.addEventListener('input', () => { filter(input.value); });
input.addEventListener('keydown', (e) => {
const visible = getVisibleItems(list);
if (e.key === 'ArrowDown') {
e.preventDefault();
highlightIndex = highlightItem(list, highlightIndex + 1);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
highlightIndex = highlightItem(list, highlightIndex - 1);
} else if (e.key === 'Enter') {
e.preventDefault();
if (visible[highlightIndex]) { visible[highlightIndex].click(); }
} else if (e.key === 'Home') {
e.preventDefault();
highlightIndex = highlightItem(list, 0);
} else if (e.key === 'End') {
e.preventDefault();
highlightIndex = highlightItem(list, visible.length - 1);
}
});
dialog.addEventListener('click', (e) => {
if (e.target === dialog) dialog.close();
if (e.target.closest('.command-item')) dialog.close();
});
dialog.addEventListener('close', () => {
input.value = '';
filter('');
list.querySelectorAll('.command-item[data-highlighted]').forEach((el) => delete el.dataset.highlighted);
highlightIndex = -1;
});
});
document.querySelectorAll('[data-command-trigger]:not([data-init])').forEach((trigger) => {
trigger.dataset.init = '';
const dialog = document.getElementById(trigger.dataset.commandTrigger);
if (!dialog) return;
trigger.addEventListener('click', () => {
dialog.showModal();
const input = dialog.querySelector('.command-input');
if (input) input.focus();
});
});
}
init();
new MutationObserver(init).observe(document, { childList: true, subtree: true });