Skip to content

Create a Custom Tool

Angular Toolbar exports its UI components so you can build custom tools that integrate seamlessly with the toolbar. Each tool follows a consistent pattern: a model defining data structures, an internal service managing state with signals, and a component that wraps its content in ToolbarToolComponent.

All components are exported from ngx-dev-toolbar and can be imported directly:

ComponentSelectorPurpose
ToolbarToolComponentndt-toolbar-toolMain wrapper providing window management, positioning, and animations
ToolbarButtonComponentndt-buttonAction buttons with optional icon and label
ToolbarInputComponentndt-inputText inputs with two-way binding via [(value)]
ToolbarSelectComponentndt-selectDropdown selection with CDK overlay positioning
ToolbarCardComponentndt-cardContent container with hover effects
ToolbarClickableCardComponentndt-clickable-cardInteractive card with icon, title, and subtitle
ToolbarListComponentndt-listList container with empty states and no-results handling
ToolbarListItemComponentndt-list-itemList items with optional badge
ToolbarStepViewComponentndt-step-viewMulti-step view switcher with back navigation
ToolbarIconComponentndt-iconSVG icons with consistent sizing
ToolbarLinkButtonComponentndt-link-buttonExternal link button with icon

Create a TypeScript interface that describes the data your tool manages:

notes.models.ts
export interface Note {
id: string;
title: string;
content: string;
createdAt: Date;
}

Use Angular signals for state management. The service should expose readonly signals and update methods:

notes-internal.service.ts
import { Injectable, signal } from '@angular/core';
import { Note } from './notes.models';
@Injectable({ providedIn: 'root' })
export class NotesInternalService {
private readonly _notes = signal<Note[]>([]);
readonly notes = this._notes.asReadonly();
addNote(note: Note): void {
this._notes.update(notes => [...notes, note]);
}
removeNote(id: string): void {
this._notes.update(notes => notes.filter(n => n.id !== id));
}
updateNote(id: string, updates: Partial<Note>): void {
this._notes.update(notes =>
notes.map(n => n.id === id ? { ...n, ...updates } : n)
);
}
}

Wrap your tool’s content in ToolbarToolComponent. This provides the window management, overlay positioning, and animations automatically:

notes-tool.component.ts
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import {
ToolbarToolComponent,
ToolbarWindowOptions,
ToolbarButtonComponent,
ToolbarInputComponent,
ToolbarListComponent,
ToolbarListItemComponent,
} from 'ngx-dev-toolbar';
import { NotesInternalService } from './notes-internal.service';
@Component({
selector: 'app-notes-tool',
standalone: true,
imports: [
ToolbarToolComponent,
ToolbarButtonComponent,
ToolbarInputComponent,
ToolbarListComponent,
ToolbarListItemComponent,
],
template: `
<ndt-toolbar-tool
[options]="windowOptions"
title="Notes"
icon="edit"
>
<ndt-list
[hasItems]="notesService.notes().length > 0"
emptyMessage="No notes yet"
>
@for (note of notesService.notes(); track note.id) {
<ndt-list-item [label]="note.title" />
}
</ndt-list>
</ndt-toolbar-tool>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class NotesToolComponent {
protected readonly notesService = inject(NotesInternalService);
readonly windowOptions: ToolbarWindowOptions = {
id: 'notes',
title: 'Notes',
description: 'Quick notes for development',
size: 'medium',
};
}

Add your tool component to the toolbar’s template in your application. Custom tools appear alongside the built-in tools in the toolbar strip.

Configure how your tool’s window appears using ToolbarWindowOptions:

window-options.ts
const windowOptions: ToolbarWindowOptions = {
id: 'my-tool', // Unique tool identifier
title: 'My Tool', // Window title
description: 'Tool info', // Optional description shown in header
isClosable: true, // Show close button (default: true)
placement: 'bottom-center', // Window position relative to toolbar
size: 'medium', // 'small' | 'medium' | 'tall' | 'large'
isBeta: false, // Show beta badge
};
// Available placements:
// 'bottom-left' | 'bottom-center' | 'bottom-right'
// 'top-left' | 'top-center' | 'top-right'
// Available sizes:
// 'small' - compact tools
// 'medium' - standard tools (default)
// 'tall' - tools with long lists
// 'large' - tools needing more space

The ToolbarIconComponent supports the following icon names via the IconName type:

angular, bolt, bug, code, database, discord, docs, edit, export, filter, flag, gauge, gear, git-branch, import, layout, lighting, lightbulb, lock, moon, network, puzzle, refresh, star, sun, terminal, toggle-left, translate, trash, user

For tools that need multiple views (e.g., list/create/edit), use the ToolbarStepViewComponent with the ngtStep directive. It handles back navigation and step switching automatically:

multi-step-tool.component.ts
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import {
ToolbarToolComponent,
ToolbarWindowOptions,
ToolbarStepViewComponent,
ToolbarStepDirective,
ToolbarButtonComponent,
} from 'ngx-dev-toolbar';
@Component({
selector: 'app-multi-step-tool',
standalone: true,
imports: [
ToolbarToolComponent,
ToolbarStepViewComponent,
ToolbarStepDirective,
ToolbarButtonComponent,
],
template: `
<ndt-toolbar-tool [options]="windowOptions" title="Items" icon="star">
<ndt-step-view
[currentStep]="viewMode()"
defaultStep="list"
(back)="viewMode.set('list')"
>
<ng-template ngtStep="list" stepTitle="All Items">
<p>Your list content here...</p>
<ndt-button (click)="viewMode.set('create')">
Create New
</ndt-button>
</ng-template>
<ng-template ngtStep="create" stepTitle="Create Item">
<p>Your create form here...</p>
</ng-template>
<ng-template ngtStep="edit" stepTitle="Edit Item">
<p>Your edit form here...</p>
</ng-template>
</ndt-step-view>
</ndt-toolbar-tool>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class MultiStepToolComponent {
viewMode = signal<'list' | 'create' | 'edit'>('list');
readonly windowOptions: ToolbarWindowOptions = {
id: 'items',
title: 'Items',
size: 'medium',
};
}
  • [currentStep] — Bind to a signal that tracks the active step name
  • [defaultStep] — The “home” step (back button is hidden on this step)
  • (back) — Emitted when the user clicks the back button
  • ngtStep — Structural directive marking each step template
  • stepTitle — Optional title shown in the header for each step

Reach for <ndt-stream-runner> when a tool kicks off a long-running, multi-entity creation flow and the user benefits from per-step, per-item progress — for example, “generate 50 invoices across 5 entity types” or “seed 100 users, then 500 posts, then 1500 comments”. The multi-step pattern above is the right call when each view is a distinct UI; the streaming runner is the right call when the work itself is the UI: a sequence of steps, each producing N items, each item flushed to the view as it resolves.

A StreamStep<T> describes one phase of the run. The required fields are label: string (shown in the header), total: number (how many items this step produces), and runItem: (index: number, ctx: StreamRunContext) => Promise<T> (invoked once per item; whatever it resolves to is what consumers see). Optional fields tune the presentation: verbActive and verbDone swap the default “Creating…” / “Created” verbs; badge, description, and link decorate the step header; describe and detail format each item’s primary label and secondary line; placeholderDetail is shown while an item is still pending.

Every visible part of a step row comes from a string field on StreamStep<T>. The runner renders those strings through Angular interpolation, so HTML in the returned values is escaped — the customization story is “compose the right strings and let the runner lay them out”, not “return markup”. Use link to turn the title into an anchor; that’s the only structural decoration available per item.

import { StreamStep } from 'ngx-dev-toolbar';
interface User {
id: string;
name: string;
role: 'admin' | 'member';
}
const usersStep: StreamStep<User> = {
// Step header (shown above the per-item list)
label: 'Users', // "5 users" / "3 of 5 users…"
badge: 'USER', // small chip rendered next to the header
description: 'Active accounts in '
+ 'various activation states.', // sub-description under the header
total: 5,
verbActive: 'Provisioning', // shown while the step is running
verbDone: 'Provisioned', // shown after the step completes
// Per-item callback (drives both the data and the rendered row)
runItem: async (i) => {
await new Promise(r => setTimeout(r, 200));
return {
id: `user-${i + 1}`,
name: `User ${i + 1}`,
role: i === 0 ? 'admin' : 'member',
};
},
// Visible parts of each item, all built from the resolved value
describe: (u) => u.name, // primary title text
detail: (u) => `id ${u.id} · ${u.role}`, // secondary line below the title
link: (u) => `/users/${u.id}`, // makes the title an anchor with a → arrow
placeholderDetail: 'Provisioning…', // detail line shown while an item is pending
};

What each field controls in the rendered row:

  • Step headerverbActive / verbDone flip the verb based on status; label appears next to the count (e.g. Provisioned 5 users); badge is a small chip; description is a single muted line below the header.
  • Per-item titledescribe(value) provides the title text. When link(value) is set, the title is wrapped in an anchor pointing at that URL with a trailing arrow.
  • Per-item detaildetail(value) produces the secondary line shown beneath the title once the item resolves. While the item is still pending, placeholderDetail is shown instead (default: "Working…").
  • Per-item icon — automatically managed by the runner based on item status (queued / running / done / error); not consumer-controllable.

For richer formatting (badges per item, multi-line details, inline icons), compose the strings yourself — e.g.

detail: (u) => `${u.role.toUpperCase()} · last seen ${u.lastSeen}`

Returning HTML or Angular templates is not currently supported.

A run is configured with StreamRunOptions: title (the run header), preamble (a short blurb shown above the first step), steps (an ordered array of StreamSteps), and pacing (controls inter-item delay so the stream feels live rather than instant).

runItem receives a StreamRunContext as its second argument. Call ctx.prior(stepIndexOrLabel) to read the resolved values from any earlier step in the same run — that’s how later steps reference entities produced upstream.

import { StreamStep, StreamRunContext } from 'ngx-dev-toolbar';
const customersStep: StreamStep<{ id: string }> = {
label: 'Customers',
total: 5,
runItem: async (i) => ({ id: `customer-${i + 1}` }),
};
const invoicesStep: StreamStep<{ id: string; customerId: string }> = {
label: 'Invoices',
total: 10,
runItem: async (i, ctx) => {
const customers = ctx.prior('Customers') as readonly { id: string }[];
const customer = customers[i % customers.length];
return { id: `invoice-${i + 1}`, customerId: customer.id };
},
};

See the live demo at /stream-runner in the demo app for a runnable Authors → Posts → Comments example.

Use the toolbar’s design tokens for consistent styling. All tokens use the --ndt- prefix:

notes-tool.component.scss
/* Use design tokens for consistent styling */
.notes-container {
display: flex;
flex-direction: column;
gap: var(--ndt-spacing-md);
padding-top: var(--ndt-spacing-md);
}
.note-item {
padding: var(--ndt-spacing-sm) var(--ndt-spacing-md);
border-radius: var(--ndt-border-radius-medium);
background: var(--ndt-background-secondary);
color: var(--ndt-text-primary);
border: 1px solid var(--ndt-border-primary);
}
.note-item__title {
font-size: var(--ndt-font-size-md);
font-weight: 600;
color: var(--ndt-text-primary);
}
.note-item__content {
font-size: var(--ndt-font-size-sm);
color: var(--ndt-text-secondary);
margin-top: var(--ndt-spacing-xs);
}
CategoryTokenUsage
Colors--ndt-background-primaryPrimary background
Colors--ndt-background-secondarySecondary background
Colors--ndt-text-primaryPrimary text
Colors--ndt-text-secondarySecondary text
Colors--ndt-text-mutedMuted text
Colors--ndt-border-primaryBorder color
Spacing--ndt-spacing-xsExtra-small spacing
Spacing--ndt-spacing-smSmall spacing
Spacing--ndt-spacing-mdMedium spacing
Spacing--ndt-spacing-lgLarge spacing
Radius--ndt-border-radius-smallSmall border radius
Radius--ndt-border-radius-mediumMedium border radius
Radius--ndt-border-radius-largeLarge border radius
Font--ndt-font-size-xsExtra-small font
Font--ndt-font-size-smSmall font
Font--ndt-font-size-mdMedium font
Font--ndt-font-size-lgLarge font

When building custom tools, follow these defensive CSS practices to ensure your tool works correctly in any host application:

  • Always use explicit spacing (padding-top, margin-top, gap) instead of relying on browser defaults
  • Use flex-shrink: 0 on headers/footers to prevent collapse
  • Use min-height: 0 on scrollable content areas
  • Use OnPush change detection for all components
  • Use Angular signals for state management (signal(), computed())
  • Keep state transformations pure and immutable (spread operators, .filter(), .map())
  • Follow the component property order: injects, inputs, outputs, signals, computed, methods
  • Use standalone: true for all components
  • Add aria-label attributes to interactive elements for accessibility
  • Use the ToolbarStorageService if you need to persist state across page reloads

Here’s a full Notes Tool that combines a model, service, and component with multi-step navigation:

notes-tool-complete.component.ts
import { ChangeDetectionStrategy, Component, inject, signal, computed } from '@angular/core';
import { Injectable } from '@angular/core';
import {
ToolbarToolComponent,
ToolbarWindowOptions,
ToolbarButtonComponent,
ToolbarInputComponent,
ToolbarListComponent,
ToolbarListItemComponent,
ToolbarStepViewComponent,
ToolbarStepDirective,
} from 'ngx-dev-toolbar';
// --- Model ---
interface Note {
id: string;
title: string;
content: string;
}
// --- Service ---
@Injectable({ providedIn: 'root' })
class NotesService {
private readonly _notes = signal<Note[]>([]);
readonly notes = this._notes.asReadonly();
add(title: string, content: string): void {
this._notes.update(notes => [
...notes,
{ id: crypto.randomUUID(), title, content }
]);
}
remove(id: string): void {
this._notes.update(notes => notes.filter(n => n.id !== id));
}
}
// --- Component ---
@Component({
selector: 'app-notes-tool',
standalone: true,
imports: [
ToolbarToolComponent,
ToolbarButtonComponent,
ToolbarInputComponent,
ToolbarListComponent,
ToolbarListItemComponent,
ToolbarStepViewComponent,
ToolbarStepDirective,
],
template: `
<ndt-toolbar-tool [options]="windowOptions" title="Notes" icon="edit">
<ndt-step-view
[currentStep]="viewMode()"
defaultStep="list"
(back)="viewMode.set('list')"
>
<ng-template ngtStep="list" stepTitle="All Notes">
<ndt-button (click)="viewMode.set('create')" icon="edit">
Add Note
</ndt-button>
<ndt-list
[hasItems]="notesService.notes().length > 0"
emptyMessage="No notes yet"
emptyHint="Click 'Add Note' to create one"
>
@for (note of notesService.notes(); track note.id) {
<ndt-list-item [label]="note.title">
<ndt-button
variant="icon"
icon="trash"
ariaLabel="Delete"
(click)="notesService.remove(note.id)"
/>
</ndt-list-item>
}
</ndt-list>
</ng-template>
<ng-template ngtStep="create" stepTitle="New Note">
<div class="form">
<ndt-input
[(value)]="newTitle"
placeholder="Note title"
ariaLabel="Note title"
/>
<ndt-input
[(value)]="newContent"
placeholder="Note content"
ariaLabel="Note content"
/>
<ndt-button (click)="onCreate()" label="Save" />
</div>
</ng-template>
</ndt-step-view>
</ndt-toolbar-tool>
`,
styles: `
.form {
display: flex;
flex-direction: column;
gap: var(--ndt-spacing-sm);
padding-top: var(--ndt-spacing-sm);
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class NotesToolComponent {
protected readonly notesService = inject(NotesService);
viewMode = signal<'list' | 'create'>('list');
newTitle = signal('');
newContent = signal('');
readonly windowOptions: ToolbarWindowOptions = {
id: 'notes',
title: 'Notes',
description: 'Quick development notes',
size: 'medium',
};
onCreate(): void {
if (this.newTitle()) {
this.notesService.add(this.newTitle(), this.newContent());
this.newTitle.set('');
this.newContent.set('');
this.viewMode.set('list');
}
}
}