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.
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.