Skip to content
LofiStack Gallery
Type: modalWeek 03

Command Palette

The quick-action search popup you know from Linear, Vercel and Raycast. Press ⌘K or Ctrl+K anywhere, type a few letters, and fuzzy search finds commands even with gaps (“dpl” finds “Deploy”). Matched letters are highlighted, results are grouped, and a Recent group appears when the search is empty. It's built on the native <dialog> element, so you get a real focus trap, Esc to close and an inert background for free.

View on GitHub

Click it or press Ctrl + K anywhere on this page. Try typing “dpl” or “stats”.

  • Selected commands appear here.

Usage

usage.tsx
import { CommandPalette } from "@/components/gallery/command-palette";

<CommandPalette
  hotkey="k"                       // ⌘K / Ctrl+K toggles it
  recentIds={["analytics"]}
  items={[
    {
      id: "deploy",
      label: "Deploy to production",
      group: "Actions",
      icon: <RocketIcon />,
      shortcut: ["⌘", "D"],
      keywords: ["ship", "release"],
      onSelect: () => deploy(),
    },
    { id: "settings", label: "Settings", group: "Pages", onSelect: () => router.push("/settings") },
  ]}
/>

// Controlled, e.g. from your own search button
<CommandPalette items={items} open={open} onOpenChange={setOpen} />

States

Closed · Open (blur backdrop + scale-in) · Empty query with Recent · Typing (fuzzy matches highlighted) · Active row (hover or arrow keys) · Disabled item (skipped) · Loading row · No results

Empty query + Recent
Fuzzy match highlighted
Disabled item (skipped)
No results

Props

  • items*

    CommandItem[]

    { id, label, onSelect, group?, icon?, shortcut?, keywords?, hint?, disabled? }

  • open / onOpenChange

    boolean / (open) => void

    Controlled mode. Leave open undefined and the palette manages itself.

  • hotkey= "k"

    string | null

    Letter that toggles the palette with ⌘ or Ctrl. null disables it.

  • placeholder= "Type a command or search…"

    string

    Search input placeholder.

  • recentIds= []

    string[]

    Item ids shown under Recent while the search is empty.

  • loading= false

    boolean

    Shows a loading row, e.g. while remote results load.

  • closeOnSelect= true

    boolean

    Close after a command runs.

  • className

    string

    Extra classes for the dialog panel.

Accessibility

  • Native modal <dialog>: focus is trapped inside, Esc closes it, and the page behind is inert.
  • The input is a combobox with aria-controls and aria-activedescendant pointing at the active option.
  • Results use the listbox / option / group roles, with aria-selected and aria-disabled.
  • Arrow keys wrap around and skip disabled items. Enter runs the active command.
  • Shortcut keys have a readable label, e.g. “Shortcut ⌘ D”.
  • Clicking the backdrop closes it, and the open animation is disabled under prefers-reduced-motion.

Build prompt

The final prompt used to build this component
Build a reusable React 19 + TypeScript + Tailwind v4 component called CommandPalette: a ⌘K / Ctrl+K command launcher like Linear or Raycast.

Props (typed): items (CommandItem = { id, label, onSelect, group?, icon?, shortcut?: string[], keywords?: string[], hint?, disabled? }), open + onOpenChange (controlled) or uncontrolled, hotkey (letter used with ⌘/Ctrl, default "k", null disables), placeholder, recentIds, loading, closeOnSelect, className.

Behaviour:
- Built on the native <dialog> with showModal() for a real focus trap, Esc (via the cancel event) and an inert page. A blurred, dimmed ::backdrop. Clicking the backdrop closes it. Lock page scroll while open. Panel sits 12vh from the top, max 640px wide, with a scale + fade-in keyframe.
- Global keydown listener toggles it on ⌘/Ctrl + hotkey.
- Mount the body only while open so the query and active row reset every time.
- Fuzzy search: subsequence match on the label (bonus for consecutive letters, word starts and exact substrings), falling back to keywords at a lower score. Highlight matched letters with <mark>. Sort by score, then group by item.group. With an empty query, show a "Recent" group from recentIds, then all items grouped.
- Keyboard: ArrowUp/Down move the active row (wrapping, skipping disabled), Enter runs onSelect, then close if closeOnSelect. Hover also sets the active row. Keep the active row scrolled into view.
- Rows: icon tile, label + muted hint, <kbd> shortcut chips, and an ↵ icon on the active row. A footer shows ↑↓ navigate, ↵ select, esc close and the result count. A loading row and a "No results for …" empty state.

Accessibility: the input is a combobox (aria-controls, aria-activedescendant, aria-expanded). The list uses listbox/option/group roles with aria-selected and aria-disabled. Dark and light mode, no dependencies, animation off under prefers-reduced-motion.