Fast, composable, unstyled command menu for Angular. Directly inspired from pacocoursey/cmdk
@ngxpert/cmdk is a command menu Angular component that can also be used as an accessible combobox. You render items, it filters and sorts them automatically. @ngxpert/cmdk supports a fully composable API, so you can wrap items in other components or even as static HTML.
Demo and examples: ngxpert.github.io/cmdk
- π¨ Un-styled, so that you can provide your own styles easily
- π₯ Provides wrapper, so that you can pass your own template, component or static HTML
- π Default filtering present
- πΌοΈ Drop in stylesheet themes provided
- βΏ Accessible
@ngxpert/cmdk | Angular |
---|---|
1.x | >=16 <17 |
2.x | >=17 <18 |
3.x | >=18 |
For older versions
You can use @ngneat/cmdk
package from npm.
## First, install dependencies
## For Angular v16
npm install @ngneat/overview@5 @ngneat/until-destroy@10 @angular/cdk@16
## For Angular v17
npm install @ngneat/overview@6 @ngneat/until-destroy@10 @angular/cdk@17
## Then library
npm install @ngxpert/cmdk
Same as npm
, just instead of npm install
, write yarn add
.
This is taken care with ng add @ngxpert/cmdk
import { CmdkModule } from '@ngxpert/cmdk';
@NgModule({
imports: [
CmdkModule,
],
})
export class AppModule {}
import { AppComponent } from './src/app.component';
import {
CommandComponent,
GroupComponent,
InputDirective,
ItemDirective,
ListComponent,
EmptyDirective
} from '@ngxpert/cmdk';
@Component({
selector: 'app-root',
standalone: true,
imports: [
CommandComponent,
InputDirective,
ListComponent,
GroupComponent,
ItemDirective,
EmptyDirective
],
})
<cmdk-command>
<input cmdkInput />
<div *cmdkEmpty>No results found.</div>
<cmdk-list>
<cmdk-group label="Letters">
<button cmdkItem>a</button>
<button cmdkItem>b</button>
<cmdk-separator></cmdk-separator>
<button cmdkItem>c</button>
</cmdk-group>
</cmdk-list>
<button cmdkItem>Apple</button>
</cmdk-command>
Each component has a specific class (starting with cmdk-
) that can be used for styling.
Render this to show the command menu.
Selector | Class |
---|---|
cmdk-command |
.cmdk-command |
Name | Description |
---|---|
@Input() ariaLabel: string |
Accessible Label for this command menu. Not shown visibly. |
@Input() filter?: ((value: string, search: string) => boolean) |
Custom filter function for whether each command menu item should matches the given search query. It should return a boolean , false being hidden entirely. You can pass null to disable default filtering. Default: (value, search) => value.toLowerCase().includes(search.toLowerCase()) |
@Input() value?: string |
Optional controlled state of the selected command menu item. |
@Input() loading?: boolean |
Optional indicator to show loader |
@Input() loop?: boolean |
Optionally set to true to turn on looping around when using the arrow keys. |
@Output() valueChanged: EventEmitter<string> |
Event handler called when the selected item of the menu changes. |
Render this to show the command input.
Selector | Class |
---|---|
input[cmdkinput] |
.cmdk-input |
Name | Description |
---|---|
@Input() updateOn: 'blur' | 'change' | 'input' |
Optional indicator to provide event listener when filtering should happen. Default: input |
Contains items and groups.
Selector | Class |
---|---|
cmdk-list |
.cmdk-list |
Animate height using the --cmdk-list-height
CSS variable.
.cmdk-list {
min-height: 300px;
height: var(--cmdk-list-height);
max-height: 500px;
transition: height 100ms ease;
}
To scroll item into view earlier near the edges of the viewport, use scroll-padding:
.cmdk-list {
scroll-padding-block-start: 8px;
scroll-padding-block-end: 8px;
}
Name | Description |
---|---|
@Input() ariaLabel?: string |
Accessible Label for this command menu. Not shown visibly. |
Item that becomes active on pointer enter. You should provide a unique value
for each item, but it will be automatically inferred from the .textContent
.
Items will not unmount from the DOM, rather the cmdk-hidden
attribute is applied to hide it from view. This may be relevant in your styling.
State | Selector | Class |
---|---|---|
Default | [cmdkItem] |
.cmdk-item |
Active | [cmdkItem][aria-selected] |
.cmdk-item-active |
Filtered | [cmdkItem] |
.cmdk-item-filtered |
Disabled | [cmdkItem] |
.cmdk-item-disabled |
Hidden (not-filtered) | [cmdkItem][cmdk-hidden] |
`` |
Name | Description |
---|---|
value: string | undefined; |
Contextual Value of the list-item |
@Input() disabled: boolean |
Contextually mark the item as disabled. Keyboard navigation will skip this item. |
@Input() filtered: boolean |
Contextually mark the item as filtered. |
@Output() selected: EventEmitter<void> |
Event handler called when the item is selected |
Groups items together with the given label (.cmdk-group-label
).
Selector | Class |
---|---|
cmdk-group |
.cmdk-group |
Groups will not unmount from the DOM, rather the cmdk-hidden
attribute is applied to hide it from view. This may be relevant in your styling.
Name | Description |
---|---|
@Input() label: Content |
Label for this command group. Can be HTML string |
@Input() ariaLabel?: string |
Accessible Label for this command menu. Not shown visibly. |
Automatically renders when there are no results for the search query.
Selector | Class |
---|---|
*cmdkEmpty |
.cmdk-empty |
This will be conditionally renderer when you pass loading=true
with cmdk-command
Selector | Class |
---|---|
*cmdkLoader |
.cmdk-loader |
Code snippets for common use cases.
Often selecting one item should navigate deeper, with a more refined set of items. For example selecting "Change themeβ¦" should show new items "Dark theme" and "Light theme". We call these sets of items "pages", and they can be implemented with simple state:
<cmdk-command (keydown)="onKeyDown($event)">
<input cmdkInput (input)="setSearch($event)" />
<ng-container *ngIf="!page">
<button cmdkItem (selected)="setPages('projects')">Search projects...</button>
<button cmdkItem (selected)="setPages('teams')">Join a team...</button>
</ng-container>
<ng-container *ngIf="page === 'projects'">
<button cmdkItem>Project A</button>
<button cmdkItem>Project B</button>
</ng-container>
<ng-container *ngIf="page === 'teams'">
<button cmdkItem>Team 1</button>
<button cmdkItem>Team 2</button>
</ng-container>
</cmdk-command>
pages: Array<string> = [];
search = '';
get page() {
return this.pages[this.pages.length - 1];
}
onKeyDown(e: KeyboardEvent) {
// Escape goes to previous page
// Backspace goes to previous page when search is empty
if (e.key === 'Escape' || (e.key === 'Backspace' && !this.search)) {
e.preventDefault();
this.pages = this.pages.slice(0, -1);
}
}
setSearch(ev: Event) {
this.search = (ev.target as HTMLInputElement)?.value;
}
setPages(page: string) {
this.pages.push(page);
}
Render the items as they become available. Filtering and sorting will happen automatically.
<cmdk-command [loading]="loading">
<input cmdkInput />
<div *cmdkLoader>Fetching words...</div>
<button cmdkItem *ngFor="let item of items" [value]="item">
{{item}}
</button>
</cmdk-command>
loading = false;
getItems() {
this.loading = true;
setTimeout(() => {
this.items = ['A', 'B', 'C', 'D'];
this.loading = false;
}, 3000);
}
We recommend using the Angular CDK Overlay. @ngxpert/cdk relies on the Angular CDK, so this will reduce your bundle size a bit due to shared dependencies.
First, configure the trigger component:
<button (click)="isDialogOpen = !isDialogOpen" cdkOverlayOrigin #trigger="cdkOverlayOrigin" [attr.aria-expanded]="isDialogOpen">
Actions
<kbd>β</kbd>
<kbd>K</kbd>
</button>
<ng-template
cdkConnectedOverlay
[cdkConnectedOverlayOrigin]="trigger"
[cdkConnectedOverlayOpen]="isDialogOpen"
>
<app-sub-command-dialog [value]="value"></app-sub-command-dialog>
</ng-template>
isDialogOpen = false;
listener(e: KeyboardEvent) {
if (e.key === 'k' && (e.metaKey || e.altKey)) {
e.preventDefault();
if (this.isDialogOpen) {
this.isDialogOpen = false;
} else {
this.isDialogOpen = true;
}
}
}
ngOnInit() {
document.addEventListener('keydown', (ev) => this.listener(ev));
}
ngOnDestroy() {
document.removeEventListener('keydown', (ev) => this.listener(ev));
}
Then, render the cmdk-command
inside CDK Overlay content:
<div class="cmdk-submenu">
<cmdk-command>
<cmdk-list>
<cmdk-group [label]="value">
<button cmdkItem *ngFor="let item of items" [value]="item.label">
{{ item.label }}
</button>
</cmdk-group>
</cmdk-list>
<input cmdkInput #input placeholder="Search for actions..." />
</cmdk-command>
</div>
readonly items: Array<{ label: string }> = [
{
label: 'Open Application',
},
{
label: 'Show in Finder',
},
{
label: 'Show Info in Finder',
},
{
label: 'Add to Favorites',
},
];
ngAfterViewInit() {
this.input.nativeElement.focus();
}
You can find global stylesheets to drop in as a starting point for styling. See ngxpert/cmdk/styles for examples.
You can include the SCSS
stylesheet in your application's style file:
// Global is needed for any theme
@use "~@ngxpert/cmdk/styles/scss/globals";
// Then add theme
@use "~@ngxpert/cmdk/styles/scss/framer";
// @use "~@ngxpert/cmdk/styles/scss/vercel";
// @use "~@ngxpert/cmdk/styles/scss/linear";
// @use "~@ngxpert/cmdk/styles/scss/raycast";
or, use pre-built CSS
file in angular.json
// ...
"styles": [
"...",
"node_modules/@ngxpert/cmdk/styles/globals.css"
"node_modules/@ngxpert/cmdk/styles/framer.css"
],
// ...
Accessible? Yes. Labeling, aria attributes, and DOM ordering tested with Voice Over and Chrome DevTools.
Virtualization? No. Good performance up to 2,000-3,000 items, though. Read below to bring your own.
Filter/sort items manually? Yes. Pass filter={yourFilter}
to Command. Better memory usage and performance. Bring your own virtualization this way.
Unstyled? Yes, use the listed CSS selectors.
Weird/wrong behavior? Make sure your [cdkItem]
has a unique value
.
Listen for βK automatically? No, do it yourself to have full control over keybind context.
Thanks goes to these wonderful people (emoji key):
Dharmen Shah οΈοΈοΈοΈβΏοΈ π» π π¨ π π‘ π€ π§ π¦ π π¬ |
Netanel Basal π¬ πΌ π π€ π§ π§βπ« π π¬ π |
Paco π¨ π π€ π¬ |
This project follows the all-contributors specification. Contributions of any kind welcome!