# Command Palette — React Command Palette

> A grouped command palette with fuzzy matching that highlights matched letters, a highlight that glides between rows on arrow keys, recents and footer hints.

- Category: [Command & Search](https://pixelpapa.com/components/command.md)
- Tags: command, palette, search, keyboard, fuzzy
- Dependencies: lucide-react, motion (plus React and Tailwind CSS v4)
- Last updated: 2026-11-13
- Live preview: https://pixelpapa.com/components/command/command-palette

## Install

```bash
npx shadcn@latest add "https://pixelpapa.com/r/command-palette.json"
```

The file is written to `components/pixelpapa/command-palette.tsx`. To install manually, run `npm install lucide-react motion` and copy the source below into your project. It imports `cn` from `@/lib/utils`, which every shadcn project has.

## Source

```tsx
"use client";

import { Clock, CornerDownLeft, CreditCard, FileText, Moon, Plus, Search, Settings, UserPlus, Users, type LucideIcon } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { useId, useMemo, useRef, useState, type KeyboardEvent, type ReactNode } from "react";
import { cn } from "@/lib/utils";

export type CommandItem = { id: string; label: string; group: string; icon: LucideIcon; shortcut?: string[]; hint?: string };

type CommandPaletteProps = {
  items: CommandItem[];
  placeholder?: string;
  onSelect?: (item: CommandItem) => void;
  onClose?: () => void;
  className?: string;
};

/** Subsequence match. Returns matched indices and a score that favours runs and word starts. */
function fuzzy(text: string, query: string) {
  const t = text.toLowerCase();
  const q = query.toLowerCase().replace(/\s+/g, "");
  const hits: number[] = [];
  let score = 0;
  let from = 0;
  for (const ch of q) {
    const at = t.indexOf(ch, from);
    if (at === -1) return null;
    score += at === from ? 3 : 1;
    if (at === 0 || t[at - 1] === " ") score += 4;
    hits.push(at);
    from = at + 1;
  }
  return { hits, score: score - t.length * 0.01 };
}

function Marked({ text, hits }: { text: string; hits: number[] }) {
  if (!hits.length) return <>{text}</>;
  const out: ReactNode[] = [];
  text.split("").forEach((ch, i) =>
    out.push(hits.includes(i) ? <mark key={i} className="bg-transparent font-semibold text-primary">{ch}</mark> : ch),
  );
  return <>{out}</>;
}

export function CommandPalette({ items, placeholder = "Type a command or search…", onSelect, onClose, className }: CommandPaletteProps) {
  const uid = useId();
  const reduce = useReducedMotion();
  const list = useRef<HTMLDivElement>(null);
  const [query, setQuery] = useState("");
  const [active, setActive] = useState(0);

  const results = useMemo(() => {
    const matched = items
      .map((item) => ({ item, m: query ? fuzzy(item.label, query) : { hits: [], score: 0 } }))
      .filter((r): r is { item: CommandItem; m: { hits: number[]; score: number } } => r.m !== null);
    if (query) matched.sort((a, b) => b.m.score - a.m.score);
    const groups: string[] = [];
    matched.forEach((r) => !groups.includes(r.item.group) && groups.push(r.item.group));
    // Flatten in group order so arrow keys follow what is on screen.
    return groups.flatMap((g) => matched.filter((r) => r.item.group === g));
  }, [items, query]);

  function move(to: number) {
    const next = (to + results.length) % Math.max(results.length, 1);
    setActive(next);
    list.current?.querySelector(`[data-index="${next}"]`)?.scrollIntoView({ block: "nearest" });
  }

  function onKeyDown(e: KeyboardEvent<HTMLInputElement>) {
    if (e.key === "ArrowDown") (e.preventDefault(), move(active + 1));
    else if (e.key === "ArrowUp") (e.preventDefault(), move(active - 1));
    else if (e.key === "Home") (e.preventDefault(), move(0));
    else if (e.key === "End") (e.preventDefault(), move(results.length - 1));
    else if (e.key === "Enter" && results[active]) onSelect?.(results[active].item);
    else if (e.key === "Escape") query ? setQuery("") : onClose?.();
  }

  return (
    <div className={cn("w-full overflow-hidden rounded-2xl border border-border bg-popover shadow-2xl shadow-primary/10", className)}>
      <div className="flex items-center gap-3 border-b border-border px-4">
        <Search className="size-4 shrink-0 text-muted-foreground" />
        <input
          role="combobox"
          aria-expanded
          aria-controls={`${uid}-list`}
          aria-activedescendant={results[active] ? `${uid}-${results[active].item.id}` : undefined}
          aria-label="Command palette"
          value={query}
          onChange={(e) => (setQuery(e.target.value), setActive(0))}
          onKeyDown={onKeyDown}
          placeholder={placeholder}
          className="h-12 w-full bg-transparent text-sm text-popover-foreground outline-none placeholder:text-muted-foreground"
        />
        <kbd className="rounded-md border border-border bg-muted px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground">esc</kbd>
      </div>

      <div ref={list} id={`${uid}-list`} role="listbox" aria-label="Commands" className="max-h-72 overflow-y-auto p-2">
        {results.length === 0 && (
          <p className="px-3 py-8 text-center text-sm text-muted-foreground">
            No commands match <span className="font-medium text-popover-foreground">“{query}”</span>
          </p>
        )}
        {results.map(({ item, m }, i) => {
          const Icon = item.icon;
          const first = i === 0 || results[i - 1].item.group !== item.group;
          return (
            <div key={item.id}>
              {first && <p className="px-3 pt-2 pb-1 text-[11px] font-medium tracking-wide text-muted-foreground uppercase">{item.group}</p>}
              <div
                id={`${uid}-${item.id}`}
                role="option"
                aria-selected={i === active}
                data-index={i}
                onPointerMove={() => i !== active && setActive(i)}
                onClick={() => onSelect?.(item)}
                className={cn("relative flex h-10 cursor-pointer items-center gap-3 rounded-lg px-3 text-sm", i === active ? "text-accent-foreground" : "text-popover-foreground")}
              >
                {i === active && (
                  <motion.span
                    layoutId={`command-palette-active-${uid}`}
                    className="absolute inset-0 rounded-lg bg-accent"
                    transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 520, damping: 38 }}
                  />
                )}
                <Icon className={cn("relative size-4 shrink-0", i === active ? "text-primary" : "text-muted-foreground")} />
                <span className="relative truncate">
                  <Marked text={item.label} hits={m.hits} />
                </span>
                {item.hint && <span className="relative truncate text-xs text-muted-foreground">{item.hint}</span>}
                {item.shortcut && (
                  <span className="relative ml-auto flex gap-1">
                    {item.shortcut.map((k) => (
                      <kbd key={k} className="flex h-5 min-w-5 items-center justify-center rounded-[5px] border border-border bg-background px-1 font-sans text-[11px] text-muted-foreground">
                        {k}
                      </kbd>
                    ))}
                  </span>
                )}
              </div>
            </div>
          );
        })}
      </div>

      <div className="flex items-center gap-4 border-t border-border bg-muted/40 px-4 py-2 text-[11px] text-muted-foreground">
        <span className="flex items-center gap-1.5">
          <kbd className="rounded border border-border bg-background px-1 font-mono">↑↓</kbd> Navigate
        </span>
        <span className="flex items-center gap-1.5">
          <kbd className="flex h-4 items-center rounded border border-border bg-background px-1">
            <CornerDownLeft className="size-2.5" />
          </kbd>
          Run
        </span>
        <span className="ml-auto tabular-nums">
          {results.length} {results.length === 1 ? "result" : "results"}
        </span>
      </div>
    </div>
  );
}

const items: CommandItem[] = [
  { id: "r1", label: "Q4 pricing draft", group: "Recent", icon: Clock, hint: "Edited 2h ago" },
  { id: "r2", label: "Onboarding checklist", group: "Recent", icon: Clock, hint: "Edited yesterday" },
  { id: "a1", label: "New document", group: "Actions", icon: Plus, shortcut: ["⌘", "N"] },
  { id: "a2", label: "Invite teammate", group: "Actions", icon: UserPlus, shortcut: ["⌘", "I"] },
  { id: "a3", label: "Toggle dark mode", group: "Actions", icon: Moon, shortcut: ["⌘", "⇧", "L"] },
  { id: "n1", label: "Go to Documents", group: "Navigate", icon: FileText, shortcut: ["G", "D"] },
  { id: "n2", label: "Go to Members", group: "Navigate", icon: Users, shortcut: ["G", "M"] },
  { id: "n3", label: "Go to Billing", group: "Navigate", icon: CreditCard, shortcut: ["G", "B"] },
  { id: "n4", label: "Go to Settings", group: "Navigate", icon: Settings, shortcut: ["G", "S"] },
];

export default function CommandPaletteDemo() {
  const [open, setOpen] = useState(true);
  const [ran, setRan] = useState<string | null>(null);

  return (
    <div
      className="relative flex h-[430px] w-full max-w-lg flex-col items-center"
      onKeyDown={(e) => {
        if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") (e.preventDefault(), setOpen((o) => !o));
      }}
    >
      <AnimatePresence mode="wait" initial={false}>
        {open ? (
          <motion.div
            key="palette"
            className="w-full"
            initial={{ opacity: 0, scale: 0.96, y: -8 }}
            animate={{ opacity: 1, scale: 1, y: 0 }}
            exit={{ opacity: 0, scale: 0.97, y: -6 }}
            transition={{ duration: 0.18, ease: [0.22, 1, 0.36, 1] }}
          >
            <CommandPalette items={items} onClose={() => setOpen(false)} onSelect={(item) => (setRan(item.label), setOpen(false))} />
          </motion.div>
        ) : (
          <motion.div key="closed" className="m-auto text-center" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}>
            {ran && (
              <p className="mb-4 text-sm text-muted-foreground">
                Ran <span className="font-medium text-foreground">{ran}</span>
              </p>
            )}
            <button
              type="button"
              onClick={() => setOpen(true)}
              className="inline-flex h-10 items-center gap-3 rounded-xl border border-border bg-card px-4 text-sm text-muted-foreground shadow-sm outline-none transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
            >
              <Search className="size-4" />
              Search or run a command
              <kbd className="rounded-md border border-border bg-muted px-1.5 py-0.5 font-mono text-[10px]">⌘K</kbd>
            </button>
          </motion.div>
        )}
      </AnimatePresence>
    </div>
  );
}
```

## AI prompt

```text
Add the "Command Palette" component from PixelPapa UI to this project.

What it is: A grouped command palette with fuzzy matching that highlights matched letters, a highlight that glides between rows on arrow keys, recents and footer hints.
Category: Command & Search. Depends on: lucide-react, motion.

1. Install it with the shadcn CLI:
   npx shadcn@latest add "https://pixelpapa.com/r/command-palette.json"
   (If the CLI is unavailable, fetch that URL, write files[0].content to components/pixelpapa/command-palette.tsx and install the listed dependencies.)
2. Import it from "@/components/pixelpapa/command-palette" and place it where I describe below.
3. Keep its animation timings and accessibility attributes. Replace the demo copy and data with content that fits my product, and use my existing theme tokens (bg-background, text-foreground, bg-primary, border-border) rather than hard-coded colours.

Where to use it: <describe the page and position here>
```

## More command & search

- [Inline Slash Menu](https://pixelpapa.com/components/command/command-inline-slash-menu.md): Type "/" on an editor line to open a block menu that filters as you type, moves with the arrow keys and turns the line into the chosen block.
- [Shortcut Cheatsheet](https://pixelpapa.com/components/command/command-shortcut-cheatsheet.md): A keyboard shortcut overlay with physical-looking key caps that press down in sequence, on a demo loop, on hover, or when you type the combo.
- [Spotlight Search](https://pixelpapa.com/components/command/command-spotlight-search.md) (Pro): A large centred search field with a results column and a preview pane that crossfades to the highlighted result as you arrow through.
