Create a Custom Tool
Overview
Section titled “Overview”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.
Available Components
Section titled “Available Components”All components are exported from ngx-dev-toolbar and can be imported directly:
| Component | Selector | Purpose |
|---|---|---|
ToolbarToolComponent | ndt-toolbar-tool | Main wrapper providing window management, positioning, and animations |
ToolbarButtonComponent | ndt-button | Action buttons with optional icon and label |
ToolbarInputComponent | ndt-input | Text inputs with two-way binding via [(value)] |
ToolbarSelectComponent | ndt-select | Dropdown selection with CDK overlay positioning |
ToolbarCardComponent | ndt-card | Content container with hover effects |
ToolbarClickableCardComponent | ndt-clickable-card | Interactive card with icon, title, and subtitle |
ToolbarListComponent | ndt-list | List container with empty states and no-results handling |
ToolbarListItemComponent | ndt-list-item | List items with optional badge |
ToolbarStepViewComponent | ndt-step-view | Multi-step view switcher with back navigation |
ToolbarIconComponent | ndt-icon | SVG icons with consistent sizing |
ToolbarLinkButtonComponent | ndt-link-button | External link button with icon |
Step-by-Step Guide
Section titled “Step-by-Step Guide”1. Define Your Model
Section titled “1. Define Your Model”Create a TypeScript interface that describes the data your tool manages:
export interface Note { id: string; title: string; content: string; createdAt: Date;}2. Create the Internal Service
Section titled “2. Create the Internal Service”Use Angular signals for state management. The service should expose readonly signals and update methods:
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) ); }}3. Build the Component
Section titled “3. Build the Component”Wrap your tool’s content in ToolbarToolComponent. This provides the window
management, overlay positioning, and animations automatically:
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', };}4. Register Your Tool
Section titled “4. Register Your Tool”Add your tool component to the toolbar’s template in your application. Custom tools appear alongside the built-in tools in the toolbar strip.
Window Options
Section titled “Window Options”Configure how your tool’s window appears using ToolbarWindowOptions:
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 spaceAvailable Icons
Section titled “Available Icons”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
Multi-Step Tools
Section titled “Multi-Step Tools”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:
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', };}How it Works
Section titled “How it Works”[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 buttonngtStep— Structural directive marking each step templatestepTitle— Optional title shown in the header for each step
Streaming Runner
Section titled “Streaming Runner”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.
StreamStep<T>
Section titled “StreamStep<T>”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.
Anatomy of a rendered step
Section titled “Anatomy of a rendered step”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 header —
verbActive/verbDoneflip the verb based on status;labelappears next to the count (e.g.Provisioned 5 users);badgeis a small chip;descriptionis a single muted line below the header. - Per-item title —
describe(value)provides the title text. Whenlink(value)is set, the title is wrapped in an anchor pointing at that URL with a trailing→arrow. - Per-item detail —
detail(value)produces the secondary line shown beneath the title once the item resolves. While the item is still pending,placeholderDetailis 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.
StreamRunOptions
Section titled “StreamRunOptions”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).
Chaining steps with ctx.prior(...)
Section titled “Chaining steps with ctx.prior(...)”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.
Styling
Section titled “Styling”Use the toolbar’s design tokens for consistent styling. All tokens use the --ndt- prefix:
/* 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);}Design Tokens Reference
Section titled “Design Tokens Reference”| Category | Token | Usage |
|---|---|---|
| Colors | --ndt-background-primary | Primary background |
| Colors | --ndt-background-secondary | Secondary background |
| Colors | --ndt-text-primary | Primary text |
| Colors | --ndt-text-secondary | Secondary text |
| Colors | --ndt-text-muted | Muted text |
| Colors | --ndt-border-primary | Border color |
| Spacing | --ndt-spacing-xs | Extra-small spacing |
| Spacing | --ndt-spacing-sm | Small spacing |
| Spacing | --ndt-spacing-md | Medium spacing |
| Spacing | --ndt-spacing-lg | Large spacing |
| Radius | --ndt-border-radius-small | Small border radius |
| Radius | --ndt-border-radius-medium | Medium border radius |
| Radius | --ndt-border-radius-large | Large border radius |
| Font | --ndt-font-size-xs | Extra-small font |
| Font | --ndt-font-size-sm | Small font |
| Font | --ndt-font-size-md | Medium font |
| Font | --ndt-font-size-lg | Large font |
Defensive CSS
Section titled “Defensive CSS”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: 0on headers/footers to prevent collapse - Use
min-height: 0on scrollable content areas
Best Practices
Section titled “Best Practices”- 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
ToolbarStorageServiceif you need to persist state across page reloads
Complete Example
Section titled “Complete Example”Here’s a full Notes Tool that combines a model, service, and component with multi-step navigation:
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'); } }}