command palette.

A keyboard-first command palette with fuzzy matching and one highlight that travels between rows.

react / keyboard / search / a11y

Loading component...

live component, try it

what to expect.

Type a few letters, like "arcrl"
Use the arrow keys to move the highlight
Rest the mouse on a row, then arrow: it keeps your place

how it works.

The half nobody handles is who owns the selection. A cursor resting over row three steals it the instant the list scrolls under it while you are arrowing, because the browser fires pointerover at a stationary cursor whenever the element beneath it changes, and a palette that loses your place mid-keystroke is worse than one with no mouse support at all. So hover is ignored until a real pointermove says the mouse actually moved, and every arrow key disarms it again. The highlight is the other half: one element that travels between rows rather than a class that jumps, placed without travelling on the first render so the palette never opens with a bar sliding in from nowhere.

the component.

import {
  useEffect,
  useLayoutEffect,
  useRef,
  useState,
  type KeyboardEvent,
  type CSSProperties,
} from "react";
import { match, nextIndex, type Ranges } from "./filter";

export interface Item {
  id: string;
  label: string;
  hint: string;
}

export interface PaletteProps {
  items: Item[];
  max: number;
  travel: number;
  hoverSteals: boolean;
}

function Marked({ text, ranges }: { text: string; ranges: Ranges }) {
  if (ranges.length === 0) return <>{text}</>;
  const out = [];
  let at = 0;
  for (const [start, end] of ranges) {
    if (start > at) out.push(text.slice(at, start));
    out.push(<mark key={start}>{text.slice(start, end)}</mark>);
    at = end;
  }
  if (at < text.length) out.push(text.slice(at));
  return <>{out}</>;
}

export default function CommandPalette({ items, max, travel, hoverSteals }: PaletteProps) {
  const [query, setQuery] = useState("");
  const [selected, setSelected] = useState(0);
  const listRef = useRef<HTMLUListElement>(null);
  const barRef = useRef<HTMLDivElement>(null);
  const hoverArmed = useRef(false);
  const armed = useRef(false);
  const [armedState, setArmedState] = useState(false);

  const results = items
    .map((item) => ({ item, ranges: match(query, item.label) }))
    .filter((r): r is { item: Item; ranges: Ranges } => r.ranges !== null)
    .slice(0, max);

  useEffect(() => {
    setSelected(0);
  }, [query]);

  useLayoutEffect(() => {
    const row = listRef.current?.children[selected] as HTMLElement | undefined;
    const bar = barRef.current;
    if (!row || !bar) return;
    bar.style.transform = `translateY(${row.offsetTop}px)`;
    bar.style.height = `${row.offsetHeight}px`;
    const wrap = row.closest(".cp-list-wrap") as HTMLElement | null;
    if (wrap) {
      const top = row.offsetTop;
      const bottom = top + row.offsetHeight;
      if (top < wrap.scrollTop) wrap.scrollTop = row.previousElementSibling ? top : 0;
      else if (bottom > wrap.scrollTop + wrap.clientHeight) wrap.scrollTop = bottom - wrap.clientHeight;
    }
    // Place the highlight without animating on first render, then enable travel.
    if (!armed.current) {
      armed.current = true;
      requestAnimationFrame(() => setArmedState(true));
    }
  }, [selected, results.length]);

  const onKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
    if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return;
    e.preventDefault();
    // Arrow keys own the selection until the mouse actually moves again.
    hoverArmed.current = false;
    setSelected((s) => nextIndex(s, e.key === "ArrowDown" ? 1 : -1, results.length));
  };

  const activeId = results[selected] ? `cp-${results[selected]!.item.id}` : undefined;

  return (
    <div
      className="cp"
      onPointerMove={() => {
        // Only a real pointermove re-enables hover; scrolling fires pointerover at a still cursor.
        hoverArmed.current = true;
      }}
    >
      <input
        className="cp-input"
        type="text"
        role="combobox"
        aria-expanded="true"
        aria-controls="cp-list"
        aria-activedescendant={activeId}
        aria-label="search"
        placeholder="type a command"
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        onKeyDown={onKeyDown}
      />

      <div className="cp-list-wrap" style={{ "--cp-travel": `${travel}ms` } as CSSProperties}>
        <div className="cp-bar" ref={barRef} data-armed={String(armedState)} aria-hidden="true" />
        <ul className="cp-list" id="cp-list" role="listbox" aria-label="results" ref={listRef}>
          {results.map(({ item, ranges }, i) => (
            <li
              key={item.id}
              id={`cp-${item.id}`}
              role="option"
              aria-selected={i === selected}
              className="cp-row"
              data-on={String(i === selected)}
              onPointerEnter={() => {
                if (hoverSteals || hoverArmed.current) setSelected(i);
              }}
            >
              <span>
                <Marked text={item.label} ranges={ranges} />
              </span>
              <span className="cp-hint">{item.hint}</span>
            </li>
          ))}
          {results.length === 0 && <li className="cp-empty">nothing matches.</li>}
        </ul>
      </div>
    </div>
  );
}