Skip to content
Plinth

Navigation

Command Palette

A filtering command menu with wrapping arrow keys, highlighted matches and aria-activedescendant.

#navigation#command#keyboard#search#combobox

Install

npx shadcn@latest add https://plinthui.com/r/command-palette.json
Open in v0
'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>
    </>
  );
}

More in Navigation

Similar tags