Navigation
Command Palette
A filtering command menu with wrapping arrow keys, highlighted matches and aria-activedescendant.
Original by Plinth · MIT
Report a problem or suggest an improvementInstall
npx shadcn@latest add https://plinthui.com/r/command-palette.json'use client';
import { useId, useMemo, useRef, useState } from 'react';
const css = `
.cmdk {
width: min(100%, 420px);
overflow: hidden;
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: #16171d;
box-shadow: 0 24px 60px -24px rgba(0, 0, 0, 0.9);
}
.cmdk__field {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 14px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.cmdk__icon {
width: 16px; height: 16px;
flex: none;
fill: none;
stroke: rgba(255, 255, 255, 0.4);
stroke-width: 1.8;
stroke-linecap: round;
}
.cmdk__input {
flex: 1;
min-width: 0;
border: 0;
background: none;
color: #f3f0e9;
font: 400 14px/1.4 ui-sans-serif, system-ui, sans-serif;
outline: none;
}
.cmdk__input::placeholder { color: rgba(255, 255, 255, 0.32); }
.cmdk__kbd {
flex: none;
padding: 2px 6px;
border-radius: 5px;
border: 1px solid rgba(255, 255, 255, 0.14);
color: rgba(255, 255, 255, 0.42);
font: 500 10px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace;
}
.cmdk__list {
max-height: 190px;
margin: 0;
padding: 6px;
overflow-y: auto;
list-style: none;
}
.cmdk__item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
border-radius: 8px;
color: rgba(243, 240, 233, 0.72);
font: 400 13.5px/1.3 ui-sans-serif, system-ui, sans-serif;
cursor: pointer;
}
/* Selection is an attribute, not :hover — the keyboard drives it, and a mouse
resting elsewhere must not fight the arrow keys for it. */
.cmdk__item[aria-selected="true"] {
background: rgba(255, 202, 122, 0.14);
color: #f3f0e9;
}
.cmdk__item mark {
background: none;
color: #ffca7a;
font-weight: 600;
}
.cmdk__hint {
margin-left: auto;
color: rgba(255, 255, 255, 0.3);
font: 500 10px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace;
}
.cmdk__empty {
margin: 0;
padding: 22px 14px;
text-align: center;
color: rgba(255, 255, 255, 0.36);
font: 400 13px/1.4 ui-sans-serif, system-ui, sans-serif;
}
`;
export interface Command {
label: string;
hint?: string;
}
const DEFAULT_COMMANDS: Command[] = [
{ label: 'Search components', hint: '/' },
{ label: 'Copy AI prompt', hint: '⌘P' },
{ label: 'Toggle theme', hint: '⌘T' },
{ label: 'Open saved', hint: '⌘S' },
{ label: 'New collection', hint: '⌘N' },
{ label: 'Copy install command', hint: '⌘I' },
];
/** Splits a label around the query so the match can be marked without innerHTML. */
function parts(label: string, query: string): [string, string, string] {
if (!query) return [label, '', ''];
const at = label.toLowerCase().indexOf(query.toLowerCase());
if (at < 0) return [label, '', ''];
return [label.slice(0, at), label.slice(at, at + query.length), label.slice(at + query.length)];
}
export function CommandPalette({
commands = DEFAULT_COMMANDS,
onRun,
}: {
commands?: Command[];
onRun?: (command: Command) => void;
}) {
const [query, setQuery] = useState('');
const [active, setActive] = useState(0);
const listRef = useRef<HTMLUListElement>(null);
const id = useId();
const listId = `${id}-list`;
const matches = useMemo(
() => commands.filter(c => c.label.toLowerCase().includes(query.trim().toLowerCase())),
[commands, query],
);
const index = Math.min(active, Math.max(0, matches.length - 1));
const activeId = matches.length ? `${id}-option-${index}` : undefined;
function move(delta: number) {
if (!matches.length) return;
// Wraps, so holding a direction cycles rather than dead-ending.
const next = (index + delta + matches.length) % matches.length;
setActive(next);
listRef.current?.children[next]?.scrollIntoView({ block: 'nearest' });
}
return (
<>
<style>{css}</style>
<div className="cmdk">
<div className="cmdk__field">
<svg className="cmdk__icon" viewBox="0 0 24 24" aria-hidden="true">
<circle cx="11" cy="11" r="6.5" />
<path d="m16 16 4.5 4.5" />
</svg>
<input
className="cmdk__input"
type="text"
role="combobox"
aria-expanded="true"
aria-haspopup="listbox"
placeholder="Type a command…"
aria-label="Search commands"
aria-controls={listId}
aria-autocomplete="list"
// Focus never leaves the input, so this is how a screen reader is
// told which option the arrow keys have landed on.
aria-activedescendant={activeId}
value={query}
onChange={e => {
setQuery(e.target.value);
setActive(0);
}}
onKeyDown={e => {
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault();
move(e.key === 'ArrowDown' ? 1 : -1);
}
if (e.key === 'Enter') {
e.preventDefault();
const command = matches[index];
if (command) {
setQuery(command.label);
onRun?.(command);
}
}
if (e.key === 'Escape') {
setQuery('');
setActive(0);
}
}}
/>
<kbd className="cmdk__kbd">esc</kbd>
</div>
<ul className="cmdk__list" id={listId} ref={listRef} role="listbox" aria-label="Commands">
{matches.map((command, i) => {
const [before, hit, after] = parts(command.label, query.trim());
return (
<li
key={command.label}
id={`${id}-option-${i}`}
className="cmdk__item"
role="option"
aria-selected={i === index}
onClick={() => {
setQuery(command.label);
onRun?.(command);
}}
// Pointer sets selection so mouse and keyboard share one notion
// of "active", rather than a hover style competing with arrows.
onPointerMove={() => i !== index && setActive(i)}
>
<span>
{before}
{hit ? <mark>{hit}</mark> : null}
{after}
</span>
{command.hint ? <span className="cmdk__hint">{command.hint}</span> : null}
</li>
);
})}
</ul>
{matches.length === 0 ? <p className="cmdk__empty">No matches</p> : null}
</div>
</>
);
}