I've been building Angular apps professionally for years now, and one thing that's bugged me for a long time is the lack of a proper command palette.
They're everywhere these days. GitHub, Raycast, VS Code, angular.dev. You press Cmd+K (or something similar), start typing, and you're where you need to be in seconds. Once you've used one regularly, navigating an app without it feels slow.
In React, cmdk has become the go-to solution. Angular has @ngxpert/cmdk, but it's intentionally a low-level primitive. You still need to build the overlay, wire everything together, decide how commands are registered, handle searching, and generally do a lot of the heavy lifting yourself.
That means every Angular project that wants a command palette ends up solving the same problems over and over again.
So I built @theryansmee/ngx-command-palette.
The goal was to make adding a command palette to an Angular application take a few minutes. Drop in a command palette and your routes become searchable automatically. Add custom commands when you need them, and customise as much or as little as you want.
To show what that looks like in practice, I'll walk through adding it to a project management app. This example application is a Jira-style tool with projects, kanban boards, sprints, team management, and issue tracking.
Getting Started
Install the package:
ng add @theryansmee/ngx-command-palette
The schematic handles everything, but if you want to do it manually, it's two steps:
// app.config.ts
import { provideCommandPalette } from '@theryansmee/ngx-command-palette';
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(routes),
provideCommandPalette({
placeholder: 'Search commands, issues, projects...',
maxResults: 15,
debounce: 100,
}),
],
};
<!-- app.html -->
<cmd-palette />
<router-outlet />
That's the whole setup. Press Cmd+K (or Ctrl+K on Windows/Linux) and you've got a working command palette.
Routes become commands automatically
This is the main thing that sets this library apart. It steps through your router config and turns every route into a searchable command. Here's what our routes look like:
export const routes: Routes = [
{
path: '',
component: ShellComponent,
children: [
{
path: 'dashboard',
component: DashboardComponent,
title: 'Dashboard',
data: {
commandPalette: {
category: 'Navigation',
keywords: ['home', 'overview', 'stats'],
priority: 10,
},
},
},
{
path: 'projects',
component: ProjectsComponent,
title: 'All Projects',
data: {
commandPalette: {
category: 'Navigation',
keywords: ['list', 'browse'],
priority: 9,
},
},
},
{
path: 'project/:projectKey',
component: ProjectNavComponent,
children: [
{
path: 'board',
component: BoardComponent,
title: 'Board',
data: {
commandPalette: {
category: 'Views',
keywords: ['kanban', 'columns', 'drag'],
},
},
},
{
path: 'backlog',
component: BacklogComponent,
title: 'Backlog',
data: {
commandPalette: {
category: 'Views',
keywords: ['unscheduled', 'grooming', 'upcoming'],
},
},
},
// ... list, timeline views
],
},
{
path: 'team',
component: TeamComponent,
title: 'Team',
data: {
commandPalette: {
category: 'Navigation',
keywords: ['members', 'people', 'engineers'],
},
},
},
{
path: 'settings',
component: SettingsComponent,
title: 'Settings',
data: {
commandPalette: {
category: 'Navigation',
keywords: ['preferences', 'config'],
},
},
},
],
},
];
Every titled route is immediately searchable. The data.commandPalette block is optional; it just lets you add keywords, set a category for grouping, or bump the priority so important pages rank higher. For example, when we type "kan", the Board view shows up because "kanban" is one of its keywords..
Child routes are walked recursively, and lazy-loaded modules get picked up as they load. You don't maintain a separate list of palette items that slowly drifts out of sync with your routing.
How the search ranking works (Fuzzy Search)
I spent a fair bit of time on the search scoring because basic substring matching doesn't cut it for a command palette. The engine scores results across multiple signals:
- Exact match on the label scores highest
- Prefix match is next (label starts with your query)
- Word boundary match catches things like "dash" matching "Dashboard"
- Fuzzy character match handles abbreviations and rough typing, with consecutive character matches scoring higher
- Keywords contribute to the score but are capped below label matches, so searching "kanban" surfaces the Board view without outranking something literally called "Kanban"
Additionally, if you enable recent command tracking, your recently used commands get a recency boost. Any command can also have a priority value for manual ranking.
Custom Commands
Routes handle navigation, but our project management tool needs action commands too. Here's what we register then in its shell component:
@Component({ ... })
export class ShellComponent {
readonly #palette = inject(CommandPaletteService);
readonly #destroyRef = inject(DestroyRef);
readonly #themeService = inject(ThemeService);
readonly #createIssueDialog = inject(CreateIssueDialogService);
readonly #dataService = inject(DataService);
readonly #router = inject(Router);
constructor() {
this.#registerGlobalCommands();
this.#registerProjectNavigation();
}
#registerGlobalCommands(): void {
this.#palette.register(
[
{
id: 'create-issue',
label: 'Create New Issue',
category: 'Actions',
shortcut: 'Cmd+N',
keywords: ['new', 'add', 'task', 'bug', 'ticket'],
priority: 8,
action: () => this.#createIssueDialog.open(),
},
{
id: 'toggle-theme',
label: `Toggle Theme (${this.#themeService.theme() === 'dark' ? 'Light' : 'Dark'})`,
category: 'Account',
keywords: ['dark', 'light', 'mode', 'appearance'],
action: () => {
const next = this.#themeService.effectiveTheme() === 'dark' ? 'light' : 'dark';
this.#themeService.setTheme(next);
},
},
{
id: 'toggle-sidebar',
label: 'Toggle Sidebar',
category: 'Appearance',
keywords: ['collapse', 'expand', 'navigation', 'menu'],
action: () => this.#themeService.toggleSidebar(),
},
],
this.#destroyRef,
);
}
}
Passing DestroyRef means these commands are automatically cleaned up when the component is destroyed. So you don't need to manually tear anything down in ngOnDestroy.
The example app has three projects (TaskFlow Platform, Mobile App, Design System), and each has board, list, timeline, and backlog views. Instead of hardcoding all twelve combinations, we generate them:
#registerProjectNavigation(): void {
const projects = this.#dataService.projects();
const views = ['Board', 'List', 'Timeline', 'Backlog'];
const projectCommands: Command[] = projects.flatMap(project =>
views.map(view => ({
id: `nav-${project.key}-${view.toLowerCase()}`,
label: `${project.name} — ${view}`,
category: 'Projects',
keywords: [project.key.toLowerCase(), view.toLowerCase()],
action: () => this.#router.navigate(
['/project', project.key, view.toLowerCase()]
),
})),
);
this.#palette.register(projectCommands, this.#destroyRef);
}
Now typing "TF board" or "mobile timeline" instantly takes you to the right view. As projects are added, the palette updates automatically.
Contextual Commands
Not every command makes sense everywhere. Our apps Board View registers a "Clear Filters" command, but only when a filter is actually active, and only when you're on the board:
@Component({ ... })
export class BoardComponent {
readonly #palette = inject(CommandPaletteService);
readonly #destroyRef = inject(DestroyRef);
filterAssignee = signal('');
constructor() {
this.#palette.register(
[
{
id: 'board-filter-clear',
label: 'Clear Filters',
category: 'Actions',
keywords: ['reset', 'all', 'remove'],
context: {
routes: ['/project/*/board'],
when: () => !!this.filterAssignee(),
},
action: () => this.filterAssignee.set(''),
},
],
this.#destroyRef,
);
}
}
The routes array also supports glob patterns (* for one segment, ** for any depth). The when function gets re-evaluated each time the palette opens. Both conditions must pass for the command to show up. So "Clear Filters" only appears when you're looking at a board and you've actually filtered by assignee.
Async Search Providers
This feature is a must on most sites that deal with a lot of dynamic data. Our example app has hundreds of issues and a team of people. You don't want those in the palette as static commands. Instead, you register search providers that hit a specific API as the user types.
Here's an example of how the issue search is scoped behind a # prefix, so that it doesn't fire on every keystroke:
this.#palette.registerProvider(
{
id: 'issue-search-prefix',
category: 'Issues',
prefix: '#',
placeholder: 'Search issues by key, title, or description...',
emptyMessage: 'No issues found.',
debounce: 200,
minQueryLength: 1,
order: 2,
search: (query) => this.#mockApi.searchIssues(query).pipe(
map(response => response.data.map(issue => {
const project = this.#dataService.getProjectById(issue.projectId);
const assignee = issue.assigneeId
? this.#dataService.getUserById(issue.assigneeId)
: undefined;
return {
id: `issue:${issue.id}`,
label: issue.title,
category: 'Issues',
action: () => this.#router.navigate(['/issue', issue.key]),
data: {
key: issue.key,
status: issue.status,
priority: issue.priority,
projectName: project?.name,
assigneeName: assignee?.name,
assigneeColor: assignee?.avatarColor,
},
};
})),
),
},
this.#destroyRef,
);
Type #TF-12 and it searches issues by key. Type #auth bug and it searches titles and descriptions. The prefix shows up as a chip in the input so users know they're in issue search mode.
We can do the same for User Search using @ as a prefix.
this.#palette.registerProvider(
{
id: 'user-search',
category: 'People',
prefix: '@',
placeholder: 'Search users by name...',
emptyMessage: 'No users found.',
debounce: 150,
minQueryLength: 1,
order: 1,
search: (query) => this.#mockApi.searchUsers(query).pipe(
map(response => response.data.map(user => ({
id: `user:${user.id}`,
label: user.name,
category: 'People',
action: () => this.#router.navigate(['/team/member', user.id]),
data: {
email: user.email,
role: user.role,
avatarColor: user.avatarColor,
initials: user.name.split(' ').map(n => n[0]).join(''),
},
}))),
),
},
this.#destroyRef,
);
Type @sarah and only the user API fires. Type #auth and only the issue API fires. Type dashboard with no prefix and it just searches static commands. (Each provider has its own debounce and minimum query length, and a loading indicator appears automatically while requests are in flight).
Registered prefixes show up as hints in the palette footer so users can discover them.
Custom Item Templates
The default rendering is an icon, a label, and an optional shortcut badge. That's fine for navigation commands, but issue and user search results need richer formatting. Using custom item templates, we can show status badges, priority indicators, avatar initials, etc:
<cmd-palette>
<ng-template cmdItemTemplate="People" let-command let-active="active">
<div class="user-result">
<span class="avatar" [style.background-color]="command.data?.['avatarColor']">
{{ command.data?.['initials'] }}
</span>
<div class="user-info">
<span class="name">{{ command.label }}</span>
<span class="email">{{ command.data?.['email'] }}</span>
</div>
<span class="role-badge">{{ command.data?.['role'] }}</span>
</div>
</ng-template>
<ng-template cmdItemTemplate="Issues" let-command let-active="active">
<div class="issue-result">
<span class="issue-key">{{ command.data?.['key'] }}</span>
<span class="issue-title">{{ command.label }}</span>
<span class="status-badge" [attr.data-status]="command.data?.['status']">
{{ command.data?.['status'] }}
</span>
</div>
</ng-template>
</cmd-palette>
Templates are resolved by category name. The "People" template only applies to results from the user search provider (which sets category: 'People'). The "Issues" template catches issue results. Everything else falls back to the built-in default.
The data property on commands is a Record<string, unknown>, so you attach whatever metadata your templates need when building the search results. The palette doesn't care what's in there. It just passes it through.
Theming
ngx-command-palette includes a few built-in themes; default, dark, github, and linear. These can be set in the config or bound dynamically:
provideCommandPalette({ theme: 'github' })
<!-- or switch at runtime -->
<cmd-palette [theme]="activeTheme()" />
Where it really comes into it's own is in customisation. Almost every visual property is exposed as a CSS custom variable. We leveraged these in our Project Management app's dark mode overrides:
cmd-palette {
--cmd-bg: #1e1e2e;
--cmd-border: #313244;
--cmd-input-color: #cdd6f4;
--cmd-item-color: #cdd6f4;
--cmd-item-hover-bg: #313244;
--cmd-item-active-bg: #45475a;
--cmd-group-heading-color: #a6adc8;
}
I've tried to ensure that devs can match whatever design system their app uses without fighting internal styles or duplicating selectors.
Some Boring but Important Stuff
Accessibility. The palette implements the WAI-ARIA combobox pattern. Focus trapping, focus restoration, aria-activedescendant, screen reader announcements, and keyboard navigation. Animations also respect prefers-reduced-motion.
SSR-safe. Platform checks around localStorage and DOM APIs so it won't blow up with Angular Universal or SSR.
Signal-based. All internal state uses Angular signals. No zone pollution or unnecessary change detection cycles.
Multi-version support. Separate branches and npm tags exist for Angular 19-22 (the package versions match Angular's major versions):
npm install @theryansmee/ngx-command-palette # Angular 22
npm install @theryansmee/ngx-command-palette@angular21 # Angular 21
npm install @theryansmee/ngx-command-palette@angular20 # Angular 20
npm install @theryansmee/ngx-command-palette@angular19 # Angular 19
Try It Out
If you've been wanting a command palette in your Angular app, or you've been putting off building one because the wiring is tedious, give it a try. The ng add schematic gets you a working palette in just a few seconds, and from there you can layer on custom commands, search providers, and templates as your app needs them.
Try it, open an issue, tell me what's missing... A star would be very much appreciated!
GitHub: github.com/theryansmee/ngx-command-palette
npm: @theryansmee/ngx-command-palette
Demo/Docs: theryansmee.github.io/ngx-command-palette







Top comments (0)