When the shipped kinds — universal text, template, component plus the per-adapter icon and button kinds (mat-icon / mat-button for Material, bs-icon / bs-button for Bootstrap, prime-icon / prime-button for PrimeNG, ion-icon / ion-button for Ionic) — don't cover your case (a rating widget in the prefix, a status pill in the suffix, a copy-to-clipboard component with bespoke styling), register a custom kind. Two independent steps: a runtime registration (withCustomAddon) and an optional type-level augmentation.
1. Define the addon shape
A custom kind extends BaseAddon and pins kind to a unique string literal:
import type { BaseAddon } from '@ng-forge/dynamic-forms';
export interface RatingAddon extends BaseAddon {
readonly kind: 'rating';
readonly value: number;
readonly max?: number;
}BaseAddon carries the universal axes — slot, optional hidden, optional className, optional disabled — so you only declare the kind-specific fields.
2. Build the kind component
import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core';
import type { RatingAddon } from './rating-addon';
@Component({
selector: 'app-rating-addon',
template: `
<span class="rating">
@for (i of stars(); track $index) {
<span [class.filled]="i < addon().value">★</span>
}
</span>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class RatingAddonComponent {
readonly addon = input.required<RatingAddon>();
protected readonly stars = computed(() => Array.from({ length: this.addon().max ?? 5 }, (_, i) => i));
}The contract: declare addon: input.required<TAddon>(). The dispatcher (<df-addon-slot>) wires the addon object via [addon] and forwards the slot HTML attribute on the host element. ARIA defaults are owned by your component — decorative kinds typically set aria-hidden="true"; interactive kinds handle their own labelling.
3. Register at the provider level
Define the AddonKindDefinition next to the addon shape:
import { DynamicFormError, type AddonKindDefinition } from '@ng-forge/dynamic-forms';
import type { RatingAddon } from './rating-addon';
export const RATING_KIND: AddonKindDefinition<RatingAddon> = {
kind: 'rating',
loadComponent: () => import('./rating-addon.component').then((m) => m.RatingAddonComponent),
validate: (addon, fieldKey) => {
if (typeof addon.value !== 'number' || addon.value < 0) {
throw new DynamicFormError(`Addon 'rating' on field '${fieldKey}' requires a non-negative 'value'.`);
}
},
};Then pass it through withCustomAddon(...) to provideDynamicForm alongside your adapter s field bundle:
import { ApplicationConfig } from '@angular/core';
import { provideDynamicForm, withCustomAddon } from '@ng-forge/dynamic-forms';
import { withMaterialFields } from '@ng-forge/dynamic-forms-material';
import { RATING_KIND } from './rating-addon';
export const appConfig: ApplicationConfig = {
providers: [
provideDynamicForm(...withMaterialFields(), withCustomAddon(RATING_KIND)),
],
};
loadComponent returns a Promise — the kind component is loaded lazily on first render and cached.
validate is optional; when provided, the runtime addon validator calls it at config init. Throwing DynamicFormError drops the addon with an actionable warning and the form keeps rendering — validate is a sanitisation hook, not a hard fail.
4. Type-level augmentation (optional but recommended)
To make kind: 'rating' autocomplete inside the field s addons array, augment the active adapter s addon-extension seam:
declare module '@ng-forge/dynamic-forms-material' {
interface MatAddonExtensions {
'rating': RatingAddon;
}
}
// Now valid in TS — `kind: 'rating'` is part of the mat-input addon union.
{ type: 'input', key: 'review', addons: [{ slot: 'suffix', kind: 'rating', value: 4 }] }
The runtime registration and the type-level augmentation are independent — use either or both. Without augmentation, custom kinds still work at runtime; you lose IDE narrowing on the addons array.
When not to use a custom kind
- Static decoration — pure CSS / text →
kind: 'text'covers it. - An entirely new field control (file picker, rich-text editor, color picker) → register a custom field type, not an addon kind. Addons decorate; field types render the primary control. See Adding custom fields.
- One-off behavior for a specific button — use
action(code-only) oractionRef(registered handler) on a built-in button kind.
Verification checklist
When you ship a custom kind:
- The kind component declares
addon: input.required<TAddon>(). withCustomAddon(...)is passed toprovideDynamicFormafter the field-type bundle.- The runtime
validatefunction rejects malformed configs withDynamicFormError. - The type-level augmentation lives in the same file as the runtime registration so the two stay in sync.
- ARIA defaults are explicit —
aria-hidden="true"for decorative kinds;aria-labelfor kinds that convey meaning.
Where to next
- Building an Adapter — adapter authors registering bundled kinds.
- Custom field types — when the "addon" you re building is really a new field control.