angulartailwindcsscommand-paletteaccessibilitysignalsdeveloper-experience

Building a Linear-Style Cmd+K Command Palette in Angular 22 & Tailwind v4

Base UI (Angular) — base-ui.net ships a Pro Cmd+K command palette for Angular 22 and Tailwind CSS v4. Here is how CDK focus traps, substring search, and signals wire together.

7 min read Base UI team

Base UI (Angular) — base-ui.net ships base-command-palette as a Pro copy-in component (npx base-ui-cli add command-palette). It is a Cmd+K / Ctrl+K launcher: CDK focus trapping, role="combobox" + aria-activedescendant, and signal-driven substring filtering across labels, descriptions, and groups — no extra runtime UI package in node_modules. Live demo: /base-elements/command-palette/.

Modern SaaS products like Linear, Raycast, and GitHub changed what desktop-web users expect. Nested sidebars feel slow. Power users want a single keyboard launcher that finds actions, pages, and settings in milliseconds. GitHub documents Ctrl+K (Windows/Linux) or Command+K (Mac) as the default shortcut to open the command palette (GitHub Docs).

The W3C ARIA Authoring Practices Guide (APG) defines the combobox pattern: “A combobox is a composite widget made up of the single-line textbox and an associated popup that provides values to set the value of the textbox.” WCAG 2.2 Success Criterion 2.1.1 Keyboard (Level A) requires that all functionality is available from a keyboard (W3C). Getting that right in Angular means solving four problems at once: the host shortcut, focus trapping, keyboard-driven active-item selection, and SSR-safe teardown.

Here is how base-command-palette in Base UI (Angular) solves each problem using Angular signals, the Angular CDK a11y module, and Tailwind CSS v4.


The four architectural requirements

A production command palette cannot merely be an input inside a floating div. To match native desktop feel and accessibility standards, it must fulfill four contracts:

Requirement Implementation in Base UI (Angular) Why it matters
Keyboard shortcut Host document:keydown for Cmd/Ctrl+K (the demo app shell does this); the palette handles arrows, Enter, Home, End, and Escape while open Users trigger the launcher from any route; the overlay must not steal keys when closed
Focus confinement cdkTrapFocus + cdkTrapFocusAutoCapture Tab cannot escape into the page behind the dialog
ARIA combobox contract role="combobox" + aria-activedescendant Screen readers announce the active suggestion without moving DOM focus out of the input
Zoneless signal reactivity computed() filtering + signal() active index Instant list updates without a Zone.js-driven app-wide CD cycle

Confining focus with @angular/cdk/a11y

When the overlay opens, keyboard focus must move into the search input. Tab / Shift+Tab must cycle inside the dialog, not onto background buttons.

base-command-palette delegates that to @angular/cdk/a11y instead of hand-rolled focusin listeners:

<div class="fixed top-[15vh] left-1/2 -translate-x-1/2 w-full max-w-140 bg-white dark:bg-slate-800 rounded-xl shadow-2xl border border-slate-200 dark:border-slate-700 z-1001 overflow-hidden"
     role="dialog"
     aria-modal="true"
     aria-label="Command palette"
     cdkTrapFocus
     cdkTrapFocusAutoCapture>

  <!-- Input and results container -->
</div>

cdkTrapFocusAutoCapture focuses the search input on open. Escape or a backdrop click closes the dialog and returns focus to the element that had it.


Substring filtering with Angular signals

Command palettes need instant feedback. Users type "dark", "sett", "inv" and the list must re-group with no lag. This is substring matching on label, description, and group — not Levenshtein/fuzzy ranking.

In base-command-palette, query, selection, and groups are signals:

readonly items = input<CommandItem[]>([]);
readonly query = signal('');
readonly activeIndex = signal(0);

readonly filtered = computed(() => {
  const q = this.query().toLowerCase().trim();
  if (!q) return this.items();
  return this.items().filter(
    (i) =>
      i.label.toLowerCase().includes(q) ||
      i.description?.toLowerCase().includes(q) ||
      i.group?.toLowerCase().includes(q)
  );
});

readonly groups = computed(() => {
  return [...new Set(this.filtered().map((i) => i.group ?? ''))];
});

Because filtered and groups are computed(), they re-evaluate only when query or items change. In zoneless mode (provideZonelessChangeDetection), that schedules targeted updates instead of walking the whole application tree.


The W3C ARIA combobox pattern in action

Arrow keys must keep focus in the <input> so the user can keep typing. The selected row is exposed to assistive tech with aria-activedescendant:

<input
  base-input
  type="text"
  role="combobox"
  aria-autocomplete="list"
  aria-expanded="true"
  aria-label="Search commands"
  [attr.aria-controls]="listboxId"
  [attr.aria-activedescendant]="activeDescendantId()"
  [ngModel]="query()"
  (ngModelChange)="onQueryChange($event)"
/>

<div class="max-h-100 overflow-y-auto py-2" [id]="listboxId" role="listbox" aria-label="Commands">
  @for (group of groups(); track group) {
    <!-- Grouped options with role="option" -->
  }
</div>

While the palette is open, @HostListener('document:keydown') maps keys as follows:

Key Action Accessible feedback
Cmd+K / Ctrl+K Toggle open (host, not the palette itself) Dialog appears; trap captures focus
ArrowDown / ArrowUp Move activeIndex Updates aria-activedescendant
Home / End First / last filtered item Same
Enter (selected) for the active item Closes and restores focus
Escape Close immediately Returns focus to the trigger

SSR teardown safety

The catalog prerenders. Overlay code that touches document during those hooks will throw. base-command-palette gates focus restore with isPlatformBrowser, uses injectTimers() so the post-open focus timeout is cancelled on destroy, and ngOnDestroy closes the overlay so an open state cannot leak into the hydrated page. That is why the SSR prerender gate can include this Pro widget.


Copy-in ownership: installing and customizing

The modal markup and Tailwind classes are not locked in a compiled vendor bundle. Base UI (Angular) copies the TypeScript, HTML, and spec into your repo:

npx base-ui-cli add command-palette

That writes command-palette.component.ts, command-palette.component.html, and command-palette.component.spec.ts into src/app/components/command-palette/. Command palette is Pro — the CLI authenticates against the Pro registry. Free cousin patterns for search and menus are base-input-autocomplete and base-dropdown-menu (see the free dashboard walkthrough).

Using the palette in your app

The palette does not bind Cmd+K internally. Your shell (or a layout) should, then pass [(open)]:

@HostListener('document:keydown', ['$event'])
onDocumentKeydown(event: KeyboardEvent) {
  if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') {
    event.preventDefault();
    this.isOpen.update((open) => !open);
  }
}
<button type="button" (click)="isOpen.set(true)">
  Press <kbd>⌘K</kbd> to search…
</button>

<base-command-palette
  [(open)]="isOpen"
  [items]="commands"
  (selected)="handleCommand($event)">
</base-command-palette>

Because the template lives in your repository, you can change tokens, add badges, keep recents, or call a remote search API without an upstream issue or ::ng-deep.


Summary

A command palette turns a web app into a keyboard-first workspace. Angular 22 signals, CDK focus trapping, and Tailwind v4 tokens are enough for a production launcher — as long as the host owns Cmd+K, the overlay owns the combobox contract, and prerender cannot touch document.

Live demo: base-ui.net/base-elements/command-palette/. Catalog and install:

npx base-ui-cli init
npx base-ui-cli add command-palette

Frequently asked questions

Same wording as the canonical FAQ.

Base UI (Angular) — base-ui.net — is a CLI-first Angular + Tailwind CSS component library with 211 components and blocks (121 free), delivered shadcn-style: npx base-ui-cli add <name> copies the TypeScript/HTML source into your project, where you own and edit it. There is no npm library package.

npx base-ui-cli init once per project, then npx base-ui-cli add <name>. Components land in src/app/components/<name>/ by default and are yours.

Standalone components, signals, new control flow, zoneless-ready; no zone.js dependency.

Components are keyboard-navigable with ARIA and focus states; an Accessibility Conformance Report is published at /accessibility/.

Yes. The docs site prerenders every catalog route to static HTML, and a blocking CI job fails the build if any route stops rendering.

Start with the free tier

All primitives, all 19 form blocks, and 390 icons — no account, no licence key.