@angularforge/command-palette
v0.0.1
Published
A professional, accessible command palette for Angular 20+. VS Code / Linear / Raycast-style global search, fuzzy matching, keyboard shortcuts, and Angular Signals support.
Maintainers
Readme

Playground
Explore and customize the command palette in real time using the interactive Playground.
Features
Devices Views
Table of Contents
- Features
- Requirements
- Installation
- Quick Start
- Icon Integration (ng-icons)
- Configuration
- API Reference
- Examples
- Theming
- Fuzzy Search Engine
- Architecture
- Accessibility
- Performance
- FAQ
- Local Development & Testing
- Contributing
- Stay in touch
- Support
- License
Features
- Keyboard-first —
Cmd+K/Ctrl+K(or any custom shortcut) to open/close - Fuzzy search — intelligent ranking with consecutive-character bonuses
- Grouped results — organise commands by category, navigated in visual order
- Full keyboard navigation —
↑ ↓ Home End Enter Esc, with auto-scroll to the active item - ng-icons support — Heroicons, Lucide, Bootstrap Icons (and any other ng-icons pack) via
ngIcon - Theming — CSS custom properties for every visual token; light & dark mode out of the box
- CDK Overlay — scroll blocking via
BlockScrollStrategy - CDK FocusTrap — keyboard focus locked inside the dialog
- WCAG AA — ARIA combobox/listbox pattern, focus trap, screen reader support
- OnPush everywhere — all components use
ChangeDetectionStrategy.OnPush - Signal-first — built with Angular Signals,
computed(), andeffect()(zero RxJS in state) - Standalone components — zero NgModule, fully tree-shakable
- SSR & Zoneless compatible — no direct
window/documentaccess in state - Tree-shakable —
sideEffects: false
Requirements
| Dependency | Version |
| --------------------------- | ---------- |
| @angular/core | >=20.0.0 |
| @angular/common | >=20.0.0 |
| @angular/cdk | >=20.0.0 |
| @angular/platform-browser | >=20.0.0 |
| @ng-icons/core (optional) | >=29.0.0 |
Installation
npm install @angularforge/command-palette @angular/cdkTo use icons from ng-icons, install the core package and any icon packs you need:
# Core (required for icon support)
npm install @ng-icons/core
# Icon packs — install only what you use
npm install @ng-icons/heroicons # Heroicons (outline · solid · mini · micro)
npm install @ng-icons/lucide # Lucide
npm install @ng-icons/bootstrap-icons # Bootstrap IconsQuick Start
1. Add the provider
// main.ts
import { bootstrapApplication } from "@angular/platform-browser";
import { provideCommandPalette } from "@angularforge/command-palette";
import { AppComponent } from "./app/app.component";
bootstrapApplication(AppComponent, {
providers: [provideCommandPalette()],
});2. Add the component to AppComponent
import { Component } from "@angular/core";
import { CommandPaletteComponent } from "@angularforge/command-palette";
@Component({
selector: "app-root",
imports: [CommandPaletteComponent],
template: `
<router-outlet />
<af-command-palette />
`,
})
export class AppComponent {}3. Register commands anywhere
import { Component, DestroyRef, inject, OnInit } from "@angular/core";
import { Router } from "@angular/router";
import { CommandPaletteService } from "@angularforge/command-palette";
@Component({
/* ... */
})
export class SidebarComponent implements OnInit {
private readonly palette = inject(CommandPaletteService);
private readonly router = inject(Router);
private readonly destroyRef = inject(DestroyRef);
ngOnInit(): void {
this.palette.register([
{
id: "nav-home",
label: "Home",
description: "Go to the home page",
category: "Navigation",
icon: "🏠",
keywords: ["start", "landing"],
action: () => this.router.navigate(["/"]),
},
{
id: "nav-users",
label: "Users",
category: "Navigation",
shortcut: "⌘U",
action: () => this.router.navigate(["/users"]),
},
{
id: "action-create",
label: "Create User",
description: "Open the user creation form",
category: "Actions",
action: () => this.router.navigate(["/users/new"]),
},
]);
this.destroyRef.onDestroy(() =>
this.palette.unregister(["nav-home", "nav-users", "action-create"]),
);
}
}Press Cmd+K or Ctrl+K — the palette opens and your commands appear.
Icon Integration (ng-icons)
@angularforge/command-palette has optional first-class support for ng-icons.
Icons are registered once at the app level and then referenced by name on each command — fully tree-shakable.
1. Register icons with provideCommandPaletteIcons()
Add provideCommandPaletteIcons() next to provideCommandPalette() and pass only the icons your app uses:
// app.config.ts
import {
provideCommandPalette,
provideCommandPaletteIcons,
} from "@angularforge/command-palette";
// Heroicons (outline variant)
import {
heroMagnifyingGlass,
heroCommandLine,
heroHome,
} from "@ng-icons/heroicons/outline";
// Lucide
import { lucideSettings, lucideUsers } from "@ng-icons/lucide";
// Bootstrap Icons
import {
bootstrapTerminal,
bootstrapFileText,
} from "@ng-icons/bootstrap-icons";
export const appConfig = {
providers: [
provideCommandPalette(),
provideCommandPaletteIcons({
heroMagnifyingGlass,
heroCommandLine,
heroHome,
lucideSettings,
lucideUsers,
bootstrapTerminal,
bootstrapFileText,
}),
],
};2. Use ngIcon on commands
Set the ngIcon field to the icon name string (matches the exported variable name):
this.palette.register([
{
id: "nav-home",
label: "Home",
category: "Navigation",
ngIcon: "heroHome",
action: () => this.router.navigate(["/"]),
},
{
id: "nav-users",
label: "Users",
category: "Navigation",
ngIcon: "lucideUsers",
action: () => this.router.navigate(["/users"]),
},
{
id: "action-terminal",
label: "Open Terminal",
category: "Tools",
ngIcon: "bootstrapTerminal",
action: () => openTerminal(),
},
]);Icon pack prefixes
| Pack | Install | Prefix | Example |
| ----------------- | --------------------------- | ----------- | --------------------- |
| Heroicons Outline | @ng-icons/heroicons | hero | heroMagnifyingGlass |
| Heroicons Solid | @ng-icons/heroicons | heroSolid | heroSolidHome |
| Lucide | @ng-icons/lucide | lucide | lucideSearch |
| Bootstrap Icons | @ng-icons/bootstrap-icons | bootstrap | bootstrapTerminal |
Import from the correct sub-path for Heroicons:
import { heroMagnifyingGlass } from "@ng-icons/heroicons/outline"; // outline (default)
import { heroSolidHome } from "@ng-icons/heroicons/solid"; // solid
import { heroMiniChevronDown } from "@ng-icons/heroicons/mini"; // mini (20px)
import { heroMicroCheck } from "@ng-icons/heroicons/micro"; // micro (16px)Fallback priority
Each command item resolves its icon in this order:
ngIcon— ng-icons icon by nameicon— raw SVG/HTML string viainnerHTML- Built-in dot placeholder
You can mix both fields across commands in the same palette. When both icon and ngIcon are
provided, ngIcon takes precedence.
Configuration
provideCommandPalette({
/** Keyboard shortcut(s) to open the palette. Default: ['meta+k', 'ctrl+k'] */
shortcut: "meta+k",
/** Close when Escape is pressed. Default: true */
closeOnEscape: true,
/** Enable fuzzy character matching. Default: true */
fuzzySearch: true,
/** Placeholder text inside the search input. Default: 'Search commands…' */
placeholder: "Search commands…",
/** Maximum visible results. Default: 50 */
maxResults: 50,
});Multiple shortcuts
provideCommandPalette({
shortcut: ["meta+k", "ctrl+k", "meta+shift+p"],
});Shortcut tokens are case-insensitive and accept aliases: meta/cmd, ctrl/control,
alt/option, and shift — e.g. 'cmd+shift+p'.
API Reference
provideCommandPalette(config?)
Returns EnvironmentProviders. Call once in bootstrapApplication or a route's providers array.
Accepts a partial CommandPaletteConfig; any omitted option falls back to its default.
provideCommandPaletteIcons(icons)
Registers ng-icons icon definitions for the palette. Call alongside provideCommandPalette().
Pass a record of icon name → icon SVG value — import these from the relevant @ng-icons/* pack.
provideCommandPaletteIcons({
heroMagnifyingGlass,
lucideSearch,
bootstrapTerminal,
});CommandPaletteService
Inject anywhere to control the palette or register commands.
| Method / Signal | Type | Description |
| -------------------- | ------------------------------- | ---------------------------------------------------------------------------- |
| isOpen | Signal<boolean> | Current open state |
| query | Signal<string> | Current search query |
| results | Signal<SearchResult[]> | Flat, score-ranked results |
| groups | Signal<CommandGroup[]> | Results grouped by category |
| orderedResults | Signal<SearchResult[]> | Results flattened in rendered (grouped) order — used for keyboard navigation |
| hasResults | Signal<boolean> | Shorthand for results().length > 0 |
| open() | void | Open the palette and reset the query |
| close() | void | Close the palette |
| toggle() | void | Toggle open/closed |
| register(commands) | (commands: Command[]) => void | Add or update commands by id |
| unregister(ids) | (ids: string[]) => void | Remove commands by id |
| setQuery(query) | (query: string) => void | Programmatically set the search query |
CommandPaletteComponent
Host component — place once in AppComponent. Selector: <af-command-palette>.
| Input | Type | Default | Description |
| ------------- | -------- | ------- | ------------------------------------------- |
| footerClass | string | '' | Extra CSS class(es) added to the footer bar |
Command
interface Command {
id: string;
label: string;
description?: string;
keywords?: string[];
/** Raw SVG/HTML string rendered via innerHTML. */
icon?: string;
/** ng-icons icon name — e.g. 'heroMagnifyingGlass', 'lucideSearch', 'bootstrapTerminal'. */
ngIcon?: string;
category?: string; // Group heading (default: 'Commands')
disabled?: boolean;
shortcut?: string; // Display string only — e.g. '⌘K'
action: () => void | Promise<void>;
}CommandPaletteConfig
interface CommandPaletteConfig {
shortcut?: string | string[]; // default: ['meta+k', 'ctrl+k']
closeOnEscape?: boolean; // default: true
fuzzySearch?: boolean; // default: true
placeholder?: string; // default: 'Search commands…'
maxResults?: number; // default: 50
}SearchResult & CommandGroup
interface SearchResult {
command: Command;
score: number; // see the Fuzzy Search Engine scoring tiers
}
interface CommandGroup {
category: string;
results: SearchResult[];
}Examples
Basic Setup
// main.ts
bootstrapApplication(AppComponent, {
providers: [provideCommandPalette()],
});<!-- app.component.html -->
<router-outlet />
<af-command-palette />Router Navigation
Call router.navigate() inside a command's action:
this.palette.register([
{
id: "go-settings",
label: "Settings",
category: "Navigation",
action: () => this.router.navigate(["/settings"]),
},
{
id: "go-profile",
label: "Profile",
category: "Navigation",
action: () => this.router.navigate(["/profile"]),
},
]);Custom Shortcuts
provideCommandPalette({
shortcut: ["meta+k", "ctrl+k", "meta+shift+p"],
placeholder: "Type a command…",
});Async Actions
action may return a Promise; the palette closes immediately and awaits it:
this.palette.register([
{
id: "sync-now",
label: "Sync Now",
category: "Actions",
action: async () => {
await this.api.sync();
},
},
]);Programmatic Control
const palette = inject(CommandPaletteService);
palette.open(); // open + reset query
palette.setQuery("users"); // prefill the search box
palette.toggle(); // open/close
palette.close();Lazy / Scoped Commands
Register commands from any feature component and clean them up on destroy:
ngOnInit() {
this.palette.register(this.featureCommands);
this.destroyRef.onDestroy(() =>
this.palette.unregister(this.featureCommands.map((c) => c.id))
);
}Theming
All visual tokens are CSS custom properties. Override them on the af-command-palette element or in your global CSS:
af-command-palette {
/* Dialog */
--af-command-palette-bg: #ffffff;
--af-command-palette-color: #0f172a;
--af-command-palette-border: rgba(0, 0, 0, 0.1);
--af-command-palette-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.4);
--af-command-palette-radius: 14px;
--af-command-palette-width: 640px;
/* Backdrop */
--af-command-palette-backdrop: rgba(0, 0, 0, 0.45);
--af-command-palette-backdrop-blur: 4px;
/* Text & icons */
--af-command-palette-muted: #94a3b8;
/* Active item */
--af-command-palette-item-active-bg: rgba(99, 102, 241, 0.1);
--af-command-palette-item-active-color: #4f46e5;
--af-command-palette-item-radius: 8px;
/* Keyboard hints */
--af-command-palette-kbd-bg: rgba(0, 0, 0, 0.05);
/* Footer */
--af-command-palette-footer-bg: rgba(248, 250, 252, 0.8);
/* Focus ring */
--af-command-palette-focus-ring: #6366f1;
}Dark mode is handled automatically via prefers-color-scheme: dark. To force a theme, set the
CSS variables on a wrapper element or pass an explicit class.
Example: brand colours
af-command-palette {
--af-command-palette-item-active-bg: rgba(16, 185, 129, 0.12);
--af-command-palette-item-active-color: #059669;
--af-command-palette-focus-ring: #10b981;
}Theming the footer
The footer bar (keyboard hints) is styled via --af-command-palette-footer-bg and the shared
border/muted tokens. For deeper overrides, use the footerClass input to inject a custom class:
<af-command-palette footerClass="my-footer" />/* The base .af-cp-footer class stays; your class adds on top of it */
.my-footer {
background: #1e1e2e;
border-top-color: #313244;
}
.my-footer .af-cp-footer-hint kbd {
background: #313244;
border-color: #45475a;
color: #cdd6f4;
}Multiple classes are supported — separate them with spaces:
<af-command-palette footerClass="my-footer my-footer--compact" />Fuzzy Search Engine
The built-in FuzzySearchEngine is exported so you can use it standalone or extend it. It searches
across each command's label, description, category, and keywords, and ignores disabled
commands.
import { FuzzySearchEngine } from "@angularforge/command-palette";
const engine = new FuzzySearchEngine();
const results = engine.search(commands, "usr"); // fuzzy enabled
const strict = engine.search(commands, "usr", false); // substring-onlyScoring tiers (highest wins):
| Score | Match type | | ----- | ------------------------------------------ | | 1000 | Exact match | | 800 | Target starts with query | | 600 | A word within the target starts with query | | 400 | Target contains query | | 1–199 | Fuzzy consecutive-character match |
You can also call the keyboard helpers directly:
import {
parseShortcut,
matchesShortcut,
matchesAnyShortcut,
} from "@angularforge/command-palette";
matchesAnyShortcut(event, ["meta+k", "ctrl+k"]); // booleanArchitecture
Directory Structure
src/
├── public-api.ts ← Public API surface
└── lib/
├── components/
│ ├── index.ts
│ ├── command-palette/
│ │ └── command-palette.component.ts ← Host: dialog, backdrop, footer, global shortcut
│ ├── palette-input/
│ │ └── palette-input.component.ts ← Search input (combobox)
│ ├── palette-results/
│ │ └── palette-results.component.ts ← Scrollable listbox
│ ├── palette-group/
│ │ └── palette-group.component.ts ← One category section
│ └── palette-item/
│ └── palette-item.component.ts ← One command row (option)
├── keyboard/
│ ├── index.ts
│ └── shortcut.parser.ts ← parse/match keyboard shortcuts
├── models/
│ ├── index.ts
│ └── command.model.ts ← Command / Config / SearchResult / CommandGroup
├── providers/
│ ├── index.ts
│ ├── command-palette.provider.ts ← provideCommandPalette()
│ └── command-palette-icons.provider.ts ← provideCommandPaletteIcons()
├── search/
│ ├── index.ts
│ └── fuzzy-search.engine.ts ← Dependency-free fuzzy ranking
├── services/
│ ├── index.ts
│ └── command-palette.service.ts ← Signal-based state hub
└── tokens/
├── index.ts
└── command-palette.tokens.ts ← Config injection token + defaultsComponent Tree
CommandPaletteComponent (selector: af-command-palette)
│ • host (document:keydown) → open shortcut + Escape
│ • CDK Overlay BlockScrollStrategy + CDK cdkTrapFocus
│ • reads state from CommandPaletteService (providedIn: 'root')
├── PaletteInputComponent (af-palette-input — role="combobox")
└── PaletteResultsComponent (af-palette-results — role="listbox")
└── PaletteGroupComponent × N (af-palette-group — role="group")
└── PaletteItemComponent × N (af-palette-item — role="option")Accessibility
@angularforge/command-palette is built to the WCAG AA standard:
| Feature | Status |
| ------------------------------------------ | ----------------------------------------------- |
| ARIA combobox/listbox pattern | Supported (WAI-ARIA 1.2 §3.7) |
| role="dialog" + aria-modal="true" | On the container |
| aria-activedescendant | Tracks the active option |
| role="option" + aria-selected | On every item |
| role="group" + aria-labelledby | On every category section |
| CDK FocusTrap | Tab / Shift+Tab cycles within the dialog |
| Keyboard navigation (↑ ↓ Home End Enter) | Auto-scrolls the active item into view |
| Escape closes & restores focus | Returns focus to the previously focused element |
Performance
- OnPush change detection on every component — Angular only checks when inputs change or signals emit
- Signals & computed — reactive state with no
BehaviorSubject/RxJS in state management @fortrack — keyed by command id, prevents full list re-rendersmaxResultscap — limits the rendered list for large command sets- Dependency-free fuzzy engine — no extra runtime dependencies
sideEffects: falseinpackage.json— enables full tree-shaking
FAQ
Can I open the palette programmatically?
Yes — inject CommandPaletteService and call service.open().
Can I register commands lazily (from a feature component)?
Yes — register in any component or service, and call unregister in DestroyRef.onDestroy.
Does it work with SSR / Angular Universal?
Yes. State has no direct window/document access, and CDK handles platform detection internally.
Does it work in Zoneless apps?
Yes. All state is Signal-based; there is no detectChanges or Zone dependency.
Can I disable fuzzy matching?
Yes — provideCommandPalette({ fuzzySearch: false }).
Can I use ng-icons packs other than Heroicons, Lucide, and Bootstrap?
Yes — provideCommandPaletteIcons() accepts any Record<string, string> and delegates to
provideIcons() from @ng-icons/core.
Can I have multiple palettes?
CommandPaletteService is providedIn: 'root'. For scoped palettes, provide the service at a
lower injector level.
Local Development & Testing
This package is built with ng-packagr. The output goes to ./dist.
npm run build # build the library to ./dist
npm run build:watch # rebuild on changeTesting it in another project (avoid the NG0203 trap)
Do not use npm link to test the library locally. Linking makes the bundler resolve the
library's @angular/core to its own node_modules, loading two copies of Angular, which
triggers:
NG0203: inject() must be called from an injection context …Instead, install a packed tarball — it has no nested Angular and behaves exactly like a real npm install:
# In this library
npm run build
npm pack # → angularforge-command-palette-<version>.tgz
# In the consuming app
npm install /path/to/command-palette/angularforge-command-palette-<version>.tgzIf you must use
npm link, set"preserveSymlinks": truein the consuming app'sangular.jsonbuild options so it loads a single Angular instance.
Contributing
# Clone the repo
git clone https://github.com/angularforge/command-palette
cd command-palette
# Install dependencies
npm install
# Build the library (ng-packagr)
npm run buildPlease follow Conventional Commits for all commit messages.
Stay in touch
- Author - Smerlyn Javier Eusebio Bonifacio
Support
If this library saved you time, consider buying me a coffee:
License
MIT © AngularForge
Forged with ⚒️❤️🔥 for the Angular community 🚀
