Accessible Command Palette with Keyboard Navigation in Vanilla JavaScript
Run it. Change it. Make it yours.
One-file front-end editor
Write plain HTML, CSS, JavaScript, or CDN-powered Vue and React demos.
Use Case
A command palette gives users a fast way to search for actions, settings, tools, or application features without navigating through several menus. It works especially well in dashboards, admin panels, developer tools, productivity applications, and other keyboard-friendly interfaces.
This example creates an accessible command palette using HTML, CSS, and vanilla JavaScript. The palette is open by default when the page loads, so users immediately see the available commands. After closing it, users can reopen it with Ctrl+K on Windows or Linux, Cmd+K on macOS, or the visible command-palette button.
What This Snippet Does
- Opens the command palette automatically when the page loads.
- Opens or closes with
Ctrl+KorCmd+K. - Provides a visible button for users who prefer mouse interaction.
- Filters commands in real time while the user types.
- Supports
ArrowUpandArrowDownkeyboard navigation. - Runs the selected command with
Enter. - Closes with
Escapeor by clicking the backdrop. - Supports both mouse and keyboard interaction.
- Shows command titles, descriptions, and categories.
- Displays keyboard instructions in the palette footer.
- Uses ARIA attributes for improved assistive-technology support.
- Restores focus to the previously focused element when appropriate.
Complete Snippet
<button id="openCommandPalette" class="palette-trigger" type="button">
Search Commands
<kbd>Ctrl K</kbd>
</button>
<div
id="commandPalette"
class="command-palette"
role="dialog"
aria-modal="true"
aria-labelledby="commandPaletteTitle"
>
<div class="command-palette__panel">
<div class="command-palette__header">
<div class="command-palette__title-row">
<h2 id="commandPaletteTitle">Command Palette</h2>
<kbd>Esc</kbd>
</div>
<label for="commandSearch" class="visually-hidden">
Search commands
</label>
<input
id="commandSearch"
type="search"
placeholder="Search commands..."
autocomplete="off"
aria-controls="commandList"
aria-autocomplete="list"
aria-activedescendant=""
>
</div>
<div class="command-palette__body">
<ul id="commandList" role="listbox"></ul>
</div>
<div class="command-palette__footer">
<span><kbd>↑</kbd> <kbd>↓</kbd> Navigate</span>
<span><kbd>Enter</kbd> Select</span>
<span><kbd>Esc</kbd> Close</span>
<span><kbd>Ctrl K</kbd> Toggle</span>
</div>
<p id="commandStatus" class="visually-hidden" aria-live="polite"></p>
</div>
</div>
<style>
* {
box-sizing: border-box;
}
body {
margin: 0;
padding: 24px;
color: #222;
background: #f7f7f7;
font-family: Arial, sans-serif;
}
.palette-trigger {
display: inline-flex;
align-items: center;
gap: 12px;
padding: 10px 14px;
color: #222;
background: #fff;
border: 1px solid #aaa;
border-radius: 4px;
cursor: pointer;
font: inherit;
}
.palette-trigger:hover {
background: #f5f5f5;
}
.palette-trigger:focus-visible {
outline: 2px solid #222;
outline-offset: 2px;
}
kbd {
display: inline-block;
padding: 2px 6px;
color: #333;
background: #eee;
border: 1px solid #bbb;
border-radius: 3px;
font-family: monospace;
font-size: 12px;
}
.command-palette {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
justify-content: center;
align-items: flex-start;
padding: 80px 16px 16px;
background: rgba(0, 0, 0, 0.5);
}
.command-palette[hidden] {
display: none;
}
.command-palette__panel {
width: 100%;
max-width: 620px;
overflow: hidden;
color: #222;
background: #fff;
border: 1px solid #999;
border-radius: 6px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25);
}
.command-palette__header {
padding: 16px;
border-bottom: 1px solid #ddd;
}
.command-palette__title-row {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
margin-bottom: 12px;
}
.command-palette__title-row h2 {
margin: 0;
font-size: 20px;
}
#commandSearch {
width: 100%;
padding: 10px 12px;
color: #222;
background: #fff;
border: 1px solid #aaa;
border-radius: 4px;
outline: none;
font: inherit;
}
#commandSearch:focus {
border-color: #444;
box-shadow: 0 0 0 2px #ddd;
}
.command-palette__body {
max-height: 360px;
overflow-y: auto;
padding: 8px;
}
#commandList {
list-style: none;
margin: 0;
padding: 0;
}
.command-item {
display: grid;
grid-template-columns: 1fr auto;
gap: 12px;
align-items: center;
padding: 12px;
border: 1px solid transparent;
border-radius: 4px;
cursor: pointer;
}
.command-item:hover,
.command-item[aria-selected="true"] {
background: #eee;
border-color: #ccc;
}
.command-label {
display: block;
margin-bottom: 3px;
font-weight: 600;
}
.command-description {
display: block;
color: #666;
font-size: 13px;
}
.command-category {
padding: 3px 7px;
color: #444;
background: #f0f0f0;
border: 1px solid #ccc;
border-radius: 3px;
font-size: 11px;
}
.empty-state {
padding: 32px 16px;
color: #666;
text-align: center;
}
.empty-state strong {
display: block;
margin-bottom: 4px;
color: #222;
}
.command-palette__footer {
display: flex;
flex-wrap: wrap;
gap: 16px;
padding: 10px 16px;
color: #555;
background: #f5f5f5;
border-top: 1px solid #ddd;
font-size: 12px;
}
.command-palette__footer span {
display: inline-flex;
align-items: center;
gap: 4px;
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
@media (max-width: 600px) {
body {
padding: 16px;
}
.command-palette {
padding-top: 30px;
}
.command-category {
display: none;
}
.command-item {
grid-template-columns: 1fr;
}
.command-palette__footer {
gap: 10px;
}
}
</style>
<script>
const palette = document.getElementById('commandPalette');
const searchInput = document.getElementById('commandSearch');
const commandList = document.getElementById('commandList');
const commandStatus = document.getElementById('commandStatus');
const openButton = document.getElementById('openCommandPalette');
const commands = [
{
id: 'dashboard',
label: 'Show Dashboard',
description: 'Display the main dashboard section.',
category: 'Navigation',
keywords: ['home', 'overview', 'dashboard'],
action: () => {
console.log('Dashboard selected');
}
},
{
id: 'projects',
label: 'Show Projects',
description: 'Display the projects section.',
category: 'Navigation',
keywords: ['projects', 'work', 'apps'],
action: () => {
console.log('Projects selected');
}
},
{
id: 'new-project',
label: 'Create New Project',
description: 'Start the create-project action.',
category: 'Action',
keywords: ['new', 'create', 'add', 'project'],
action: () => {
console.log('Create project selected');
}
},
{
id: 'settings',
label: 'Open Settings',
description: 'Open application preferences.',
category: 'Settings',
keywords: ['settings', 'preferences', 'options'],
action: () => {
console.log('Settings selected');
}
},
{
id: 'theme',
label: 'Toggle Theme',
description: 'Switch the application theme.',
category: 'Appearance',
keywords: ['theme', 'appearance', 'dark', 'light'],
action: () => {
document.body.classList.toggle('dark-theme');
}
}
];
let filteredCommands = [...commands];
let activeIndex = 0;
let previouslyFocusedElement = null;
function getOptionId(command) {
return `command-${command.id}`;
}
function createCommandItem(command, index) {
const item = document.createElement('li');
const content = document.createElement('span');
const label = document.createElement('span');
const description = document.createElement('span');
const category = document.createElement('span');
item.id = getOptionId(command);
item.className = 'command-item';
item.setAttribute('role', 'option');
item.setAttribute(
'aria-selected',
index === activeIndex ? 'true' : 'false'
);
label.className = 'command-label';
label.textContent = command.label;
description.className = 'command-description';
description.textContent = command.description;
category.className = 'command-category';
category.textContent = command.category;
content.append(label, description);
item.append(content, category);
item.addEventListener('mouseenter', () => {
activeIndex = index;
updateActiveCommand();
});
item.addEventListener('click', () => {
runCommand(index);
});
return item;
}
function renderCommands() {
commandList.innerHTML = '';
if (filteredCommands.length === 0) {
const emptyItem = document.createElement('li');
const title = document.createElement('strong');
const message = document.createElement('span');
emptyItem.className = 'empty-state';
emptyItem.setAttribute('role', 'option');
emptyItem.setAttribute('aria-disabled', 'true');
title.textContent = 'No commands found';
message.textContent = 'Try another search term.';
emptyItem.append(title, message);
commandList.appendChild(emptyItem);
searchInput.removeAttribute('aria-activedescendant');
commandStatus.textContent = 'No commands found.';
return;
}
filteredCommands.forEach((command, index) => {
commandList.appendChild(createCommandItem(command, index));
});
updateActiveCommand();
commandStatus.textContent = `${filteredCommands.length} commands available.`;
}
function updateActiveCommand() {
const items = commandList.querySelectorAll('.command-item');
items.forEach((item, index) => {
item.setAttribute(
'aria-selected',
index === activeIndex ? 'true' : 'false'
);
});
const activeCommand = filteredCommands[activeIndex];
if (!activeCommand) {
searchInput.removeAttribute('aria-activedescendant');
return;
}
searchInput.setAttribute(
'aria-activedescendant',
getOptionId(activeCommand)
);
document
.getElementById(getOptionId(activeCommand))
?.scrollIntoView({ block: 'nearest' });
}
function filterCommands(query) {
const normalizedQuery = query.trim().toLowerCase();
filteredCommands = commands.filter((command) => {
const searchableText = [
command.label,
command.description,
command.category,
...command.keywords
].join(' ').toLowerCase();
return searchableText.includes(normalizedQuery);
});
activeIndex = 0;
renderCommands();
}
function runCommand(index) {
const command = filteredCommands[index];
if (!command) {
return;
}
closePalette();
command.action();
}
function openPalette({ rememberFocus = true } = {}) {
if (rememberFocus) {
previouslyFocusedElement = document.activeElement;
}
palette.hidden = false;
searchInput.value = '';
filteredCommands = [...commands];
activeIndex = 0;
renderCommands();
requestAnimationFrame(() => {
searchInput.focus();
});
}
function closePalette() {
if (palette.hidden) {
return;
}
palette.hidden = true;
searchInput.value = '';
searchInput.removeAttribute('aria-activedescendant');
if (
previouslyFocusedElement instanceof HTMLElement &&
previouslyFocusedElement !== document.body
) {
previouslyFocusedElement.focus();
} else {
openButton.focus();
}
}
function moveActiveCommand(direction) {
if (filteredCommands.length === 0) {
return;
}
activeIndex =
(activeIndex + direction + filteredCommands.length) %
filteredCommands.length;
updateActiveCommand();
}
searchInput.addEventListener('input', (event) => {
filterCommands(event.target.value);
});
searchInput.addEventListener('keydown', (event) => {
if (event.key === 'ArrowDown') {
event.preventDefault();
moveActiveCommand(1);
return;
}
if (event.key === 'ArrowUp') {
event.preventDefault();
moveActiveCommand(-1);
return;
}
if (event.key === 'Enter') {
event.preventDefault();
runCommand(activeIndex);
return;
}
if (event.key === 'Escape') {
event.preventDefault();
closePalette();
}
});
document.addEventListener('keydown', (event) => {
const usesCommandShortcut =
(event.ctrlKey || event.metaKey) &&
event.key.toLowerCase() === 'k';
if (!usesCommandShortcut) {
return;
}
event.preventDefault();
if (palette.hidden) {
openPalette();
} else {
closePalette();
}
});
palette.addEventListener('click', (event) => {
if (event.target === palette) {
closePalette();
}
});
openButton.addEventListener('click', () => {
openPalette();
});
// The palette is visible in the HTML by default.
// Initialize its commands and move focus to the search field.
openPalette({ rememberFocus: false });
</script>
How It Works
- The command palette does not use the
hiddenattribute in the initial HTML, so it starts visible. - The final
openPalette({ rememberFocus: false })call initializes the command list and places keyboard focus in the search field when the page loads. - The
commandsarray stores each command's label, description, category, search keywords, and JavaScript action. - The search input filters commands every time its value changes.
renderCommands()creates the matching options and updates accessibility attributes.ArrowUpandArrowDownchange the active command while focus remains in the search field.Enterexecutes the currently active command.Escapecloses the palette.Ctrl+KorCmd+Ktoggles the palette from anywhere on the page.- After the initial page load, opening the palette remembers the user's previously focused element so focus can be restored when it closes.
Adding Your Own Command
Commands do not have to navigate to another page. A command can call any JavaScript function, open a modal, save data, update application state, trigger a filter, or perform another application action.
{
id: 'save-document',
label: 'Save Document',
description: 'Save the current document.',
category: 'Action',
keywords: ['save', 'document'],
action: () => {
saveDocument();
}
}
Add the new object to the commands array. The existing search, keyboard navigation, mouse interaction, and selection logic will automatically include it.
Customization Notes
- If you do not want the palette open when the page first loads, add the
hiddenattribute back tocommandPaletteand replace the finalopenPalette({ rememberFocus: false })call withrenderCommands(). - Replace the sample
console.log()actions with real functions from your application. - Add commands for opening modals, changing settings, saving records, filtering data, switching views, or triggering application tools.
- Change colors, borders, spacing, and typography to match your application's existing design system.
- Remove descriptions or categories when you need a more compact command list.
- Add grouped sections when the number of commands becomes large.
- Consider fuzzy searching when exact substring matching is not sufficient.
- Filter the
commandsarray according to user permissions when some actions should only be available to specific roles. - For very large command collections, consider rendering only visible results or limiting the number of matches.
Common Mistakes
Adding the Hidden Attribute While Expecting the Palette to Start Open
If the initial palette element contains hidden, the browser will hide it until JavaScript removes that state. This version intentionally leaves hidden off and explicitly initializes the palette on page load.
Forgetting to Prevent the Browser's Ctrl+K Behavior
Browsers commonly use Ctrl+K or Cmd+K for built-in search or address-bar behavior. Call event.preventDefault() only after confirming that the user pressed the shortcut your application intends to handle.
Using Links When They Are Not Needed
A command palette does not need to navigate to another page. Commands can run normal JavaScript functions, open interface elements, update application state, or perform local actions.
Moving Keyboard Focus Between Every Command
Keeping focus in the search field lets the user continue typing. The currently highlighted option can instead be represented with aria-activedescendant.
Not Restoring Focus
When a user manually opens the command palette, remember the previously focused element and return focus to it after the palette closes.
Ignoring Empty Search Results
Always show a clear empty state when no commands match the search query. This prevents users from wondering whether the interface has stopped working.
Allowing the Active Index to Point to a Missing Result
Reset activeIndex after filtering and check that an active command exists before updating aria-activedescendant or executing a command.
Related Practice Ideas
- Add command groups such as Navigation, Actions, Settings, and Tools.
- Highlight the matching portion of each search result.
- Store recently used commands in
localStorage. - Add favorite or pinned commands.
- Add individual keyboard shortcuts for frequently used actions.
- Add fuzzy search for partial and approximate matches.
- Build a TypeScript version with a reusable command interface.
- Create a React, Vue, or Next.js implementation using the same interaction pattern.
- Add automated tests for opening, closing, searching, arrow-key navigation, and command execution.
- Load available commands dynamically according to the authenticated user's permissions.