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 quick way to search for actions, settings, tools, or application features without moving through several menus. It is useful in dashboards, admin panels, developer tools, productivity applications, and other keyboard-friendly interfaces.
This snippet creates a clean command palette using HTML, CSS, and vanilla JavaScript. Users can open it with Ctrl+K or Cmd+K, search commands, move through results with the arrow keys, execute a selected command with Enter, and close the palette with Escape.
What This Snippet Does
- Opens with
Ctrl+K,Cmd+K, or a visible button. - Filters commands while the user types.
- Supports
ArrowUpandArrowDownnavigation. - Runs the selected command with
Enter. - Closes with
Escapeor by clicking outside the panel. - Supports both mouse and keyboard interaction.
- Shows clear command titles, descriptions, and categories.
- Displays keyboard instructions at the bottom of the palette.
- Uses ARIA attributes and restores keyboard focus when closed.
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"
hidden
>
<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>
</div>
<p id="commandStatus" class="visually-hidden" aria-live="polite"></p>
</div>
</div>
<style>
* {
box-sizing: border-box;
}
.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;
}
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) {
.command-palette {
padding-top: 30px;
}
.command-category {
display: none;
}
.command-item {
grid-template-columns: 1fr;
}
}
</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'],
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'],
action: () => {
console.log('Create project selected');
}
},
{
id: 'settings',
label: 'Open Settings',
description: 'Open application preferences.',
category: 'Settings',
keywords: ['settings', 'preferences'],
action: () => {
console.log('Settings selected');
}
},
{
id: 'theme',
label: 'Toggle Theme',
description: 'Switch the application theme.',
category: 'Appearance',
keywords: ['theme', 'appearance'],
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() {
previouslyFocusedElement = document.activeElement;
palette.hidden = false;
searchInput.value = '';
filteredCommands = [...commands];
activeIndex = 0;
renderCommands();
searchInput.focus();
}
function closePalette() {
palette.hidden = true;
searchInput.value = '';
searchInput.removeAttribute('aria-activedescendant');
if (previouslyFocusedElement instanceof HTMLElement) {
previouslyFocusedElement.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);
}
if (event.key === 'ArrowUp') {
event.preventDefault();
moveActiveCommand(-1);
}
if (event.key === 'Enter') {
event.preventDefault();
runCommand(activeIndex);
}
if (event.key === 'Escape') {
event.preventDefault();
closePalette();
}
});
document.addEventListener('keydown', (event) => {
const usesCommandShortcut =
(event.ctrlKey || event.metaKey) &&
event.key.toLowerCase() === 'k';
if (usesCommandShortcut) {
event.preventDefault();
if (palette.hidden) {
openPalette();
} else {
closePalette();
}
}
});
palette.addEventListener('click', (event) => {
if (event.target === palette) {
closePalette();
}
});
openButton.addEventListener('click', openPalette);
renderCommands();
</script>
How It Works
- The
commandsarray stores the label, description, category, keywords, and JavaScript action for each command. openPalette()displays the command palette and moves keyboard focus to the search field.- The search field filters commands whenever the user types.
renderCommands()creates the visible options and updates their accessibility attributes.ArrowUpandArrowDownchange the currently selected command.Enterruns the selected JavaScript action.Escapecloses the palette.- When the palette closes, focus returns to the element the user was previously using.
Adding Your Own Command
You do not need to use links or page navigation. A command can simply run any JavaScript function or application action.
{
id: 'save-document',
label: 'Save Document',
description: 'Save the current document.',
category: 'Action',
keywords: ['save', 'document'],
action: () => {
saveDocument();
}
}
Customization Notes
- Replace the sample
console.log()actions with functions from your application. - Add commands for opening modals, changing settings, saving data, filtering records, or triggering other JavaScript actions.
- Change the colors, borders, spacing, and typography to match your existing interface.
- Remove descriptions or categories if you want a more compact command list.
- Add grouped sections when the number of commands becomes large.
- Use fuzzy searching for larger command collections.
- Show only commands that are available to the current user.
Common Mistakes
Using Links When They Are Not Needed
A command palette does not need to navigate to another page. Each command can run a normal JavaScript function, open an interface element, update application state, or perform another local action.
Adding Too Much Decoration
The command list should remain easy to scan. Avoid unnecessary gradients, oversized graphics, excessive animation, or decorative elements that distract from the commands themselves.
Moving Focus Between Every Command
Keep keyboard focus inside the search field and use aria-activedescendant to represent the selected option.
Not Restoring Focus
Return focus to the previously active element when the command palette closes.
Ignoring Empty Search Results
Show a clear message when no commands match the search query.
Not Handling Keyboard Shortcuts Carefully
Use event.preventDefault() only after confirming that the shortcut should be handled by your application.
Related Practice Ideas
- Add command groups such as Actions, Settings, and Tools.
- Highlight the matching part of a search result.
- Store recently used commands in
localStorage. - Add favorite or pinned commands.
- Add individual keyboard shortcuts for common actions.
- Build a TypeScript version of the command palette.
- Create a React or Vue version using the same interaction pattern.
- Add automated keyboard-navigation and accessibility tests.