---
slug: "linear-command-palette"
name: "Linear-style Command Palette"
packType: "ui"
canonicalPattern: "n/a"
version: "0.1.0"
trust: "Community"
publisher: "Agent Workspace"
updatedAt: "2026-04-16"
---

# Linear-style Command Palette

> Cmd+K done right: grouped, keyboard-first, fully accessible.

## Summary

A production-grade Cmd+K command palette matching the Linear implementation: debounced fuzzy search, grouped sections (Issues, Projects, Actions), recent items, empty state, and WAI-ARIA 1.2 combobox semantics. Drops onto any React app via the cmdk primitive.

## Install

```sh
npx attrition-sh pack install linear-command-palette
```

### Claude Code / AGENTS.md snippet

```md
Skill `linear-command-palette` is installed at .claude/skills/linear-command-palette/SKILL.md. Invoke it whenever the user asks for a command palette, Cmd+K UI, quick-switcher, or Spotlight-style picker. Prefer cmdk + Radix Dialog; preserve the ARIA combobox pattern; never ship without debounced search and a keyboard-only test pass.
```

## Contract

_No execution contract defined for this pack type._

## Layers

_No three-layer split defined for this pack type._

## Use When

- You need fast in-app navigation across 100+ entities (issues, docs, people).
- Mouse-first menus are slowing power users down.
- You want a single surface for actions + navigation + search.
- Your users expect Linear/Vercel/GitHub-class keyboard UX.

## Avoid When

- Your app has fewer than ~20 navigation targets — a sidebar is simpler.
- The target audience is non-technical and unlikely to discover Cmd+K.
- You cannot guarantee a stable debounced backend search (<150ms p95).
- You need multi-select or complex filtering — use a data table instead.

## Key Outcomes

- p95 palette-open-to-first-keystroke under 60ms on mid-tier laptops.
- Zero mouse required: open, search, arrow-navigate, select, close.
- WAI-ARIA combobox pattern passes axe-core and NVDA smoke tests.
- Recent items surface on empty query; grouped sections on typed query.

## Minimal Instructions

## Minimal setup (plug-and-play)

```bash
pnpm add cmdk
```

```tsx
// components/command-palette.tsx
"use client";
import { Command } from "cmdk";
import { useEffect, useState } from "react";

export function CommandPalette() {
  const [open, setOpen] = useState(false);
  const [query, setQuery] = useState("");

  useEffect(() => {
    const onKey = (e: KeyboardEvent) => {
      if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
        e.preventDefault();
        setOpen((o) => !o);
      }
    };
    document.addEventListener("keydown", onKey);
    return () => document.removeEventListener("keydown", onKey);
  }, []);

  return (
    <Command.Dialog
      open={open}
      onOpenChange={setOpen}
      label="Command palette"
      shouldFilter={false} // server-side search
    >
      <Command.Input
        value={query}
        onValueChange={setQuery}
        placeholder="Search issues, projects, actions…"
      />
      <Command.List>
        <Command.Empty>No results.</Command.Empty>
        <Command.Group heading="Actions">
          <Command.Item onSelect={() => setOpen(false)}>Create issue</Command.Item>
          <Command.Item onSelect={() => setOpen(false)}>Go to inbox</Command.Item>
        </Command.Group>
      </Command.List>
    </Command.Dialog>
  );
}
```

Mount once in your root layout. That's the 80% version.

## Full Instructions

## Full reference: production palette

### 1. Architecture

A Linear-grade palette has four concerns, and each maps to a distinct module:

| Concern | Module | Notes |
|---|---|---|
| Trigger + open state | `usePaletteHotkey` | Cmd/Ctrl+K, ESC to close, scoped per-page overrides |
| Search orchestration | `usePaletteSearch` | Debounce 120ms, abort in-flight, SWR dedupe |
| Result grouping | `groupResults()` | Order: Recent > Exact > Fuzzy; cap each group at 5 |
| A11y shell | `cmdk` + Radix Dialog | Combobox semantics free out-of-the-box |

### 2. ARIA combobox pattern (WAI-ARIA 1.2)

The palette is a combobox whose popup is a listbox. `cmdk` already wires this up, but if you are rolling your own, the required attributes are:

- Wrapper: `role="combobox" aria-expanded="true" aria-haspopup="listbox" aria-controls="{listId}"`
- Input: `aria-autocomplete="list" aria-activedescendant="{activeItemId}"`
- List: `role="listbox" id="{listId}"`
- Items: `role="option" aria-selected={isActive} id="{itemId}"`

`aria-activedescendant` is the key trick: focus stays on the input while arrow keys move a virtual cursor. Screen readers announce the active option because the input's descendant changes.

### 3. Debounced search with abort

Server round-trips must not race. Use an `AbortController` keyed by query:

```tsx
function usePaletteSearch(query: string) {
  const [results, setResults] = useState<Result[]>([]);
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    if (!query) { setResults([]); return; }
    const ac = new AbortController();
    const t = setTimeout(async () => {
      setLoading(true);
      try {
        const r = await fetch(`/api/search?q=${encodeURIComponent(query)}`, { signal: ac.signal });
        setResults(await r.json());
      } catch (e) {
        if ((e as Error).name !== "AbortError") throw e;
      } finally {
        setLoading(false);
      }
    }, 120);
    return () => { clearTimeout(t); ac.abort(); };
  }, [query]);

  return { results, loading };
}
```

### 4. Grouped results

```tsx
<Command.List>
  {query === "" && recent.length > 0 && (
    <Command.Group heading="Recent">
      {recent.map((r) => <ItemRow key={r.id} item={r} />)}
    </Command.Group>
  )}
  <Command.Group heading="Issues">
    {issues.slice(0, 5).map((i) => <ItemRow key={i.id} item={i} />)}
  </Command.Group>
  <Command.Group heading="Projects">
    {projects.slice(0, 5).map((p) => <ItemRow key={p.id} item={p} />)}
  </Command.Group>
  <Command.Group heading="Actions">
    {actions.map((a) => <ActionRow key={a.id} action={a} />)}
  </Command.Group>
</Command.List>
```

### 5. Recent items

Persist the last 8 selected items to `localStorage` (keyed by workspace + user). Load synchronously on mount to avoid flash. Evict the oldest when capacity is hit. Linear's implementation also decays by last-used-timestamp rather than insertion order — worthwhile if session length is long.

### 6. Empty and loading states

- Empty + no query: show "Recent" group if any; otherwise a short tip ("Try searching for an issue or typing a command").
- Empty + query + loading: show a skeleton of 3 rows (never a spinner; spinners feel slower).
- Empty + query + done: `<Command.Empty>No results for "{query}".</Command.Empty>` plus a "Create new issue titled …" escape hatch — the move Linear popularised.

### 7. Keyboard shortcuts

| Key | Action |
|---|---|
| Cmd/Ctrl+K | Toggle |
| ↑ / ↓ | Move cursor |
| Enter | Select |
| Esc | Close (preserve query if user re-opens within 10s) |
| Cmd/Ctrl+1..9 | Jump to the Nth group's first item (Linear-specific) |
| Tab | Focus the filter chip row (if present) |

### 8. Performance budgets

- Bundle: cmdk ~3.5kB gzipped. Keep the palette chunk under 15kB including icons.
- Open-to-interactive: <60ms p95. Pre-mount the dialog `hidden`; do not lazy-import on first open.
- Search p95: 150ms end-to-end. If your backend is slower, render optimistic "Recent" hits while the fetch returns.

### 9. Accessibility checklist

- [ ] Passes axe-core with zero violations on the open palette
- [ ] NVDA announces active item on arrow navigation
- [ ] VoiceOver reads group headings
- [ ] Focus trap within dialog (Radix Dialog handles this)
- [ ] Return focus to the element that opened the palette on close
- [ ] prefers-reduced-motion: disable the open/close transform

### 10. Testing

- Unit: `groupResults()` ordering & cap logic.
- Integration (Playwright): hotkey opens, arrow navigation, enter selects, esc closes.
- A11y: `@axe-core/playwright` scan post-open.

### 11. Common pitfalls

1. Setting `shouldFilter` to `true` with server-side results — cmdk filters again client-side and your results collapse.
2. Focusing the input manually — `Command.Input` auto-focuses. Manual focus calls fight Radix's focus-lock.
3. Recent items backed by React state instead of localStorage — resets on navigation.
4. Missing `aria-activedescendant` — screen-reader users hear nothing on arrow keys.

## Evaluation Checklist

- Cmd+K on any page opens the palette in <60ms p95 (DevTools performance profile).
- Arrow keys move the highlighted item; Enter selects; Esc closes and returns focus.
- Empty query shows Recent group; typed query shows grouped Issues/Projects/Actions.
- axe-core scan on the open palette reports zero violations.
- Search fetch is debounced 100–150ms and prior in-flight requests are aborted.
- Reduced-motion preference disables the open/close transition.
- Mobile Safari: virtual keyboard does not push the input off-screen.

## Failure Modes

- **[MID] First Cmd+K press flashes a white rectangle for 200ms**
  - Trigger: cmdk lazy-imported; dialog mounts on first open
  - Prevention: Import cmdk statically; keep the dialog mounted with `open={false}`
- **[SR] Screen-reader users can't navigate results with arrow keys**
  - Trigger: Missing `aria-activedescendant` on the combobox
  - Prevention: Use cmdk (handles it) or manually wire the attribute to the active item's id
- **[MID] Results briefly show wrong matches then correct themselves**
  - Trigger: cmdk built-in client filter layered on top of server-filtered results
  - Prevention: Set `shouldFilter={false}` when your backend owns search
- **[SR] Cmd+K triggers the palette while typing in an embedded code editor**
  - Trigger: Hotkey listener fires unconditionally at window scope
  - Prevention: Skip when `event.target.isContentEditable` or Monaco/CodeMirror has focus

## Transfer Matrix

_No measured cross-model transfer data._

## Telemetry

_No telemetry recorded._

## Security Review

- Injection surface: **low**
- Tool allow-list: _none specified_
- Last scanned: 2026-04-16

### Known issues
_None reported._

## Compares With

| Compared to | Axis | Winner | Note |
| --- | --- | --- | --- |
| `shadcn-data-table` | complexity | tie | Different concerns: palette for global nav/actions, data table for structured records. Use both together. |
| `raycast-style-palette` | complexity | tie | Raycast palette adds extensions/scripting surface. Linear-style is simpler and sufficient for in-app navigation. |

## Related Packs

- `shadcn-data-table`
- `pattern-decision-tree`

## Changelog

### 0.1.0 — 2026-04-16
_Seed pack — first release._

**Added**
- Initial pack with cmdk-based minimal setup
- Full ARIA 1.2 combobox reference
- Debounced-abort search recipe
- Grouped results and recent items pattern

## Sources

- [Linear — How we built our command menu](https://linear.app/blog/how-we-built-our-command-menu) — Primary source on the original design intent: grouped results, actions-first, Cmd+K normalisation.
- [cmdk by pacocoursey](https://github.com/pacocoursey/cmdk) — The React primitive that encodes the combobox pattern. Used by Vercel, Linear's web clone, shadcn/ui.
- [WAI-ARIA 1.2 — Combobox pattern](https://www.w3.org/WAI/ARIA/apg/patterns/combobox/) — Authoritative spec for combobox + listbox semantics with aria-activedescendant.
- [shadcn/ui — Command component](https://ui.shadcn.com/docs/components/command) — Battle-tested styled wrapper over cmdk. Matches the visual grammar used in most 2025+ React apps.

## Examples

- [Linear's palette (live reference)](https://linear.app) (external)
- [shadcn/ui command demo](https://ui.shadcn.com/docs/components/command) (external)
