Skip to content
Interface

Command Palette

A command palette with fuzzy subsequence matching — "nse" finds "New Section", where substring matching would find nothing and feel broken. The visible part is easy; what is usually missing is around it. Focus is trapped inside the dialog and returned to wherever it came from on close. Focus stays in the input while aria-activedescendant points at the active option, which is the only arrangement where arrowing through results and continuing to type both keep working. Scrolling follows the selection with block: nearest, so arrowing to an off-screen row nudges the list rather than yanking it.

Loading preview…

Usage

/* Deepak Kumar E — https://craft.iam-deepak.space */ import { CommandPalette } from "@/components/commandpalette"; <CommandPalette />

Customise

The component

components/craft/interface/CommandPalette.tsx

/* Deepak Kumar E — https://craft.iam-deepak.space */ import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react"; /** * A command palette — ⌘K, filter, run. * * The visual part is easy; the reason most implementations are quietly broken * is everything around it: * * FOCUS IS TRAPPED and returned. Tab cycles inside the dialog, and on close * focus goes back to whatever was focused before it opened. Without that, a * keyboard user tabs out of an open dialog into the page behind it and is * stranded — which is a strange thing to get wrong in a component whose only * audience is keyboard users. * * THE LIST IS NOT FOCUSED, the input is. Moving DOM focus to each option as * you arrow through it means the input loses focus and typing stops working. * Instead focus stays in the input and `aria-activedescendant` points at the * active option, which is the combobox pattern and the only way both keep * working at once. * * SCROLLING FOLLOWS THE SELECTION with `nearest`, so arrowing to an item just * off-screen nudges the list by one row rather than yanking it to centre. * * Matching is subsequence, not substring: "nse" finds "New Section". Substring * matching feels broken the moment anyone types the initials they think in. */ export interface Command {  id: string;  label: string;  /** Shown right-aligned — a section name, or a keyboard hint. */  hint?: string;  keywords?: string;  run?: () => void;} /** Subsequence match with a score: earlier and tighter runs rank higher. */function score(query: string, text: string): number | null {  if (!query) return 0;  const q = query.toLowerCase();  const t = text.toLowerCase();   let ti = 0;  let points = 0;  let previous = -1;   for (const char of q) {    const found = t.indexOf(char, ti);    if (found === -1) return null;    // Adjacent characters are worth more than scattered ones, and a match at    // a word boundary is worth more again.    if (found === previous + 1) points += 3;    if (found === 0 || t[found - 1] === " ") points += 2;    points += 1;    previous = found;    ti = found + 1;  }  return points;} export interface CommandPaletteProps {  commands: Command[];  placeholder?: string;  /** Rendered inline instead of as a modal — for previews and docs. */  inline?: boolean;  open?: boolean;  onOpenChange?: (open: boolean) => void;  accent?: string;  still?: boolean;  className?: string;} export function CommandPalette({  commands,  placeholder = "Type a command…",  inline = false,  open: controlledOpen,  onOpenChange,  accent = "#c3f53c",  still = false,  className = "",}: CommandPaletteProps) {  const [uncontrolledOpen, setUncontrolledOpen] = useState(false);  const open = inline ? true : (controlledOpen ?? uncontrolledOpen);   const [query, setQuery] = useState("");  const [active, setActive] = useState(0);   const listId = useId();  const inputRef = useRef<HTMLInputElement>(null);  const listRef = useRef<HTMLUListElement>(null);  const dialogRef = useRef<HTMLDivElement>(null);  const returnFocusRef = useRef<HTMLElement | null>(null);   const setOpen = useCallback(    (next: boolean) => {      if (onOpenChange) onOpenChange(next);      else setUncontrolledOpen(next);    },    [onOpenChange],  );   const results = useMemo(() => {    const scored = commands      .map((command) => ({        command,        points: score(query, `${command.label} ${command.keywords ?? ""}`),      }))      .filter((r): r is { command: Command; points: number } => r.points !== null);     // Stable when the query is empty: authored order is meaningful, and    // re-sorting an unfiltered list shuffles it for no reason.    return query ? scored.sort((a, b) => b.points - a.points) : scored;  }, [commands, query]);   // ⌘K / Ctrl+K to open.  useEffect(() => {    if (inline) return;    const onKey = (event: KeyboardEvent) => {      if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") {        event.preventDefault();        returnFocusRef.current = document.activeElement as HTMLElement | null;        setOpen(true);      }    };    window.addEventListener("keydown", onKey);    return () => window.removeEventListener("keydown", onKey);  }, [inline, setOpen]);   /**   * Adjusted during render rather than synced from an effect.   *   * "Reset the query when the palette opens" and "highlight the first result   * when the query changes" are both derived state. Doing them in an effect   * means React commits the stale value, paints, and then immediately renders   * again — a visible flash of the previous search on every open. Comparing   * against the previous value during render is the pattern React documents   * for exactly this, and it re-renders before anything reaches the screen.   */  const [prevOpen, setPrevOpen] = useState(open);  if (open !== prevOpen) {    setPrevOpen(open);    if (open) {      setQuery("");      setActive(0);    }  }   const [prevQuery, setPrevQuery] = useState(query);  if (query !== prevQuery) {    setPrevQuery(query);    setActive(0);  }   useEffect(() => {    if (!open) return;    // Deferred a frame: focusing during the same commit that mounts the    // element can land before it is laid out, and the caret ends up nowhere.    const id = requestAnimationFrame(() => inputRef.current?.focus());    return () => cancelAnimationFrame(id);  }, [open]);   // Restores focus to where it came from, or a keyboard user is dumped at the  // top of the document.  useEffect(() => {    if (open || inline) return;    returnFocusRef.current?.focus();    returnFocusRef.current = null;  }, [open, inline]);   // Keeps the active row in view without yanking the list around.  useEffect(() => {    listRef.current      ?.querySelector<HTMLElement>(`[data-index="${active}"]`)      ?.scrollIntoView({ block: "nearest" });  }, [active]);   const choose = (command: Command) => {    command.run?.();    if (!inline) setOpen(false);  };   const onKeyDown = (event: React.KeyboardEvent) => {    if (event.key === "ArrowDown") {      event.preventDefault();      setActive((i) => (results.length ? (i + 1) % results.length : 0));    } else if (event.key === "ArrowUp") {      event.preventDefault();      setActive((i) => (results.length ? (i - 1 + results.length) % results.length : 0));    } else if (event.key === "Home") {      event.preventDefault();      setActive(0);    } else if (event.key === "End") {      event.preventDefault();      setActive(Math.max(0, results.length - 1));    } else if (event.key === "Enter") {      event.preventDefault();      const hit = results[active];      if (hit) choose(hit.command);    } else if (event.key === "Escape" && !inline) {      event.preventDefault();      setOpen(false);    } else if (event.key === "Tab" && !inline) {      // Focus trap. Only the input and the close control are tabbable, so the      // cycle is short — but it has to exist, or Tab escapes into the page      // behind an open dialog.      const focusable = dialogRef.current?.querySelectorAll<HTMLElement>(        'input, button, [href], [tabindex]:not([tabindex="-1"])',      );      if (!focusable?.length) return;      const first = focusable[0];      const last = focusable[focusable.length - 1];      if (event.shiftKey && document.activeElement === first) {        event.preventDefault();        last.focus();      } else if (!event.shiftKey && document.activeElement === last) {        event.preventDefault();        first.focus();      }    }  };   if (!open) return null;   const panel = (    <div      ref={dialogRef}      role={inline ? undefined : "dialog"}      aria-modal={inline ? undefined : true}      aria-label="Command palette"      onKeyDown={onKeyDown}      className={`w-full overflow-hidden rounded-xl border border-ink-600 bg-ink-800 shadow-2xl ${className}`}    >      <div className="border-b border-ink-600 px-4">        <input          ref={inputRef}          value={query}          onChange={(e) => setQuery(e.target.value)}          placeholder={placeholder}          role="combobox"          aria-expanded="true"          aria-controls={listId}          // Focus stays here; this is what tells assistive tech which option          // is current without moving DOM focus off the input.          aria-activedescendant={results[active] ? `${listId}-${active}` : undefined}          aria-autocomplete="list"          autoComplete="off"          spellCheck={false}          className="w-full bg-transparent py-3.5 text-sm text-bone-100 placeholder:text-bone-500 focus:outline-none"        />      </div>       <ul        ref={listRef}        id={listId}        role="listbox"        aria-label="Commands"        data-lenis-prevent        className="max-h-72 overflow-auto overscroll-contain p-2"      >        {results.length === 0 ? (          <li className="px-3 py-6 text-center font-mono text-micro text-bone-500">            Nothing matches “{query}”          </li>        ) : (          results.map(({ command }, index) => {            const isActive = index === active;            return (              <li                key={command.id}                id={`${listId}-${index}`}                data-index={index}                role="option"                aria-selected={isActive}                onPointerMove={() => setActive(index)}                onClick={() => choose(command)}                className="flex cursor-pointer items-center justify-between gap-4 rounded-md px-3 py-2.5 text-sm"                style={{                  background: isActive ? `${accent}1a` : "transparent",                  color: isActive ? accent : undefined,                  transition: still ? "none" : "background-color 120ms linear",                }}              >                <span className={isActive ? "" : "text-bone-200"}>{command.label}</span>                {command.hint ? (                  <span className="font-mono text-micro text-bone-500">{command.hint}</span>                ) : null}              </li>            );          })        )}      </ul>       <div className="flex items-center gap-4 border-t border-ink-600 px-4 py-2 font-mono text-micro text-bone-500">        <span>↑↓ navigate</span>        <span>↵ run</span>        {!inline ? <span>esc close</span> : null}      </div>    </div>  );   if (inline) return panel;   return (    <div className="fixed inset-0 z-50 flex items-start justify-center p-4 pt-[12vh]">      <div        className="absolute inset-0 bg-ink-900/70 backdrop-blur-sm"        onClick={() => setOpen(false)}        aria-hidden      />      <div className="relative w-full max-w-lg">{panel}</div>    </div>  );} 

One file. Paste it in, delete what you do not need, change what you do. The "use client" directive is stripped above — add it back if you are on the Next.js App Router.

What it costs

Weight

+3.8KB gzipped · no new dependencies

Runs on

Main thread

Runs JavaScript every frame. Heavy work here competes with your own code and with scrolling — watch the per-frame budget below.

Reduced motion

Row highlight transitions are dropped and the state change is instant. Nothing about the palette depends on animation to be understood.

Accessibility
  • role=dialog with aria-modal, and a Tab focus trap so keyboard users cannot land behind the open panel.
  • Focus returns to the element that was focused before opening.
  • combobox + listbox with aria-activedescendant: focus never leaves the input, so typing keeps working while arrowing.
  • Arrow keys wrap, Home and End jump, Enter runs, Escape closes.
  • Active row is scrolled with block: nearest rather than centred.
Watch out
  • Matching is scored in JavaScript on every keystroke. Fine to a few thousand commands; past that, debounce or index.
  • The backdrop closes on click. If a command opens a nested dialog, mount it outside this one.
  • It does not portal to the body — if an ancestor has a transform or overflow: hidden, the fixed overlay will be clipped by it.