Create custom UI integrations for ng-forge dynamic forms using any component library or design system.
Package Structure
The @ng-forge/dynamic-forms package is organized into multiple entrypoints to keep the core abstract and provide specialized utilities for integration authors:
| Entrypoint | Purpose |
|---|---|
@ng-forge/dynamic-forms |
Core types, components, and configuration (for all users) |
@ng-forge/dynamic-forms/integration |
Field types, mappers, and utilities for UI library authors |
When building a custom integration, you'll primarily import from the /integration entrypoint:
// Core types (used by everyone)
import { DynamicForm, provideDynamicForm, FormConfig, DynamicText } from '@ng-forge/dynamic-forms';
// Integration utilities (for UI library authors)
import {
InputField,
SelectField,
CheckboxField,
valueFieldMapper,
checkboxFieldMapper,
createResolvedErrorsSignal,
} from '@ng-forge/dynamic-forms/integration';Integration Overview
UI integrations map field types to your components using FieldTypeDefinition objects. Each definition specifies the field type name, component loader, mapper function, and optionally the mapped inputs that must exist before the renderer instantiates the component.
Basic Steps
1. Define Field Type Interface
Create a type interface extending the base field type:
import { ValueFieldComponent, DynamicText } from '@ng-forge/dynamic-forms';
import { InputField } from '@ng-forge/dynamic-forms/integration';
// Define your custom props
export interface CustomInputProps extends Record<string, unknown> {
appearance?: 'outline' | 'fill';
hint?: DynamicText;
type?: 'text' | 'email' | 'password' | 'number';
}
// Extend the base InputField with your props
export type CustomInputField = InputField<CustomInputProps>;
// Define the component interface (used for type checking)
export type CustomInputComponent = ValueFieldComponent<CustomInputField>;2. Create Field Component
Implement the component using Angular's signal forms:
import { Component, input } from '@angular/core';
import { Field, FieldTree } from '@angular/forms/signals';
import { DynamicText, DynamicTextPipe } from '@ng-forge/dynamic-forms';
import { CustomInputComponent, CustomInputProps } from './custom-input.type';
import { AsyncPipe } from '@angular/common';
@Component({
selector: 'custom-input',
imports: [Field, DynamicTextPipe, AsyncPipe],
template: `
@let f = field();
<div class="custom-field" [class.custom-outline]="props()?.appearance === 'outline'">
@if (label()) {
<label [for]="key() + '-input'">{{ label() | dynamicText | async }}</label>
}
<input
[id]="key() + '-input'"
[field]="f"
[type]="props()?.type || 'text'"
[placeholder]="(placeholder() | dynamicText | async) ?? ''"
[disabled]="f().disabled()"
[attr.tabindex]="tabIndex()"
/>
@if (props()?.hint; as hint) {
<div class="hint">{{ hint | dynamicText | async }}</div>
}
@if (f().touched() && f().invalid()) {
<div class="error">{{ f().errors() | json }}</div>
}
</div>
`,
host: {
'[id]': '`${key()}`',
'[class]': 'className()',
},
})
export default class CustomInputFieldComponent implements CustomInputComponent {
// Required inputs
readonly field = input.required<FieldTree<string>>();
readonly key = input.required<string>();
// Standard inputs
readonly label = input<DynamicText>();
readonly placeholder = input<DynamicText>();
readonly className = input<string>('');
readonly tabIndex = input<number>();
// Custom props
readonly props = input<CustomInputProps>();
}For full TypeScript type safety (autocomplete on
props, compile-time field validation, etc.), see Type Safety with Module Augmentation below.
3. Create Field Type Definition
Define the field type registration:
import { FieldTypeDefinition } from '@ng-forge/dynamic-forms';
import { valueFieldMapper } from '@ng-forge/dynamic-forms/integration';
export const CustomInputType: FieldTypeDefinition = {
name: 'input',
loadComponent: () => import('./custom-input.component'),
mapper: valueFieldMapper,
renderReadyWhen: ['field'],
};Use renderReadyWhen to declare which mapped inputs must be available before the component is instantiated. This is essential when your component declares input.required() for any mapped input and reads it during host bindings or computed initialization.
Built-in value and checkbox mappers may provide field reactively after initial resolution, so the renderer delays ngComponentOutlet until that input is ready. You can specify any input name that your mapper provides:
// Wait for 'field' (standard for value-bearing components)
renderReadyWhen: ['field'];
// Wait for multiple inputs
renderReadyWhen: ['field', 'title'];
// Wait for a custom input from your mapper
renderReadyWhen: ['items'];Convention: When using built-in mappers (
valueFieldMapper,checkboxFieldMapper,optionsFieldMapper, etc.),renderReadyWhen: ['field']is applied automatically if your component needs thefieldinput. You only need to declare it explicitly for custom mappers that supply other reactive inputs, or to opt out withrenderReadyWhen: []if your component doesn't needfield.
4. Create Provider Function
Export a function that returns all your field type definitions:
import { FieldTypeDefinition } from '@ng-forge/dynamic-forms';
import { CustomInputType } from './fields/input';
import { CustomSelectType } from './fields/select';
import { CustomCheckboxType } from './fields/checkbox';
export function withCustomFields(): FieldTypeDefinition[] {
return [
CustomInputType,
CustomSelectType,
CustomCheckboxType,
// ... more field types
];
}5. Configure App
Add your fields to the app configuration:
import { ApplicationConfig } from '@angular/core';
import { provideDynamicForm } from '@ng-forge/dynamic-forms';
import { withCustomFields } from './custom-fields';
export const appConfig: ApplicationConfig = {
providers: [provideDynamicForm(...withCustomFields())],
};Component Interface Types
ng-forge provides component interface types for different field categories:
ValueFieldComponent
For fields that collect user input (input, select, textarea, datepicker, radio, slider):
import { ValueFieldComponent } from '@ng-forge/dynamic-forms';
import { InputField } from '@ng-forge/dynamic-forms/integration';
export type CustomInputComponent = ValueFieldComponent<CustomInputField>;The component must implement these inputs:
field: FieldTree- The form field from Angular's signal formskey: string- Unique field identifierlabel?: DynamicText- Field labelplaceholder?: DynamicText- Placeholder textclassName?: string- CSS classestabIndex?: number- Tab orderprops?: TProps- Custom field-specific propsmeta?: FieldMeta- Native HTML attributes (data-, aria-, autocomplete, etc.)
Important: The
metainput, while technically optional in the type signature, should be implemented on all field components. It provides reactive access to native HTML attributes (data-*,aria-*,autocomplete, etc.) that are essential for accessibility, testing, and browser autofill. See Handling Meta Attributes below for implementation details.
Required input timing: If your component uses
input.required()for a mapped input likefield, declare the corresponding readiness contract on theFieldTypeDefinition(for examplerenderReadyWhen: ['field']). Otherwise Angular may instantiate the component before the mapper has supplied that input.
CheckedFieldComponent
For checkbox and toggle fields:
import { CheckedFieldComponent } from '@ng-forge/dynamic-forms';
import { CheckboxField } from '@ng-forge/dynamic-forms/integration';
export type CustomCheckboxComponent = CheckedFieldComponent<CustomCheckboxField>;Similar to ValueFieldComponent but specifically for boolean checkbox fields.
Field Binding with [field]
The key to connecting your component to Angular's signal forms is the [field] binding. Import Field and FieldTree from Angular's signal forms package:
import { Field, FieldTree } from '@angular/forms/signals';Then use the [field] directive on form controls:
<input [field]="f" ... />
<mat-checkbox [field]="f" ... />
<select [field]="f" ... />This directive automatically:
- Binds the form control value
- Handles value changes
- Manages validation state
- Syncs disabled/readonly states
Field Mappers
Mappers convert field definitions to component input bindings. ng-forge provides built-in mappers:
valueFieldMapper
For standard value-bearing fields:
import { FieldTypeDefinition } from '@ng-forge/dynamic-forms';
import { valueFieldMapper } from '@ng-forge/dynamic-forms/integration';
export const CustomInputType: FieldTypeDefinition = {
name: 'input',
loadComponent: () => import('./custom-input.component'),
mapper: valueFieldMapper, // Maps value fields
renderReadyWhen: ['field'],
};checkboxFieldMapper
For checkbox/toggle fields:
import { FieldTypeDefinition } from '@ng-forge/dynamic-forms';
import { checkboxFieldMapper } from '@ng-forge/dynamic-forms/integration';
export const CustomCheckboxType: FieldTypeDefinition = {
name: 'checkbox',
loadComponent: () => import('./custom-checkbox.component'),
mapper: checkboxFieldMapper, // Maps checkbox fields
renderReadyWhen: ['field'],
};optionsFieldMapper
For select/radio/multi-checkbox fields:
import { FieldTypeDefinition } from '@ng-forge/dynamic-forms';
import { optionsFieldMapper } from '@ng-forge/dynamic-forms/integration';
export const CustomSelectType: FieldTypeDefinition = {
name: 'select',
loadComponent: () => import('./custom-select.component'),
mapper: optionsFieldMapper, // Maps option fields
renderReadyWhen: ['field'],
};datepickerFieldMapper
For datepicker fields:
import { FieldTypeDefinition } from '@ng-forge/dynamic-forms';
import { datepickerFieldMapper } from '@ng-forge/dynamic-forms/integration';
export const CustomDatepickerType: FieldTypeDefinition = {
name: 'datepicker',
loadComponent: () => import('./custom-datepicker.component'),
mapper: datepickerFieldMapper, // Maps datepicker fields
renderReadyWhen: ['field'],
};Writing a Custom Mapper
The built-in valueFieldMapper, checkboxFieldMapper, optionsFieldMapper and datepickerFieldMapper cover most use cases. You need a custom mapper when your component expects a different set of input bindings than the standard ones — for example, a button that has no field input, or a component that reshapes props into a different structure.
A mapper is a function that receives the resolved field definition and returns an array of Binding objects. Each binding maps a field property to a component input:
import { Binding, inputBinding } from '@angular/core';
import type { BaseValueField } from '@ng-forge/dynamic-forms';
export function myCustomMapper(fieldDef: BaseValueField<string, MyProps>): Binding[] {
return [
inputBinding('key', () => fieldDef.key),
inputBinding('label', () => fieldDef.label),
inputBinding('value', () => fieldDef.value),
inputBinding('customProp', () => fieldDef.props?.myProp ?? 'default'),
inputBinding('className', () => fieldDef.className),
inputBinding('meta', () => fieldDef.meta),
];
}Then reference it in your FieldTypeDefinition:
export const MyFieldType: FieldTypeDefinition = {
name: 'my-field',
loadComponent: () => import('./my-field.component'),
mapper: myCustomMapper,
};If myCustomMapper supplies a required input reactively (for example field), add renderReadyWhen to keep rendering aligned with your component contract:
export const MyFieldType: FieldTypeDefinition = {
name: 'my-field',
loadComponent: () => import('./my-field.component'),
mapper: myCustomMapper,
renderReadyWhen: ['field'],
};Custom Mappers (Buttons Example)
For specialized fields (like buttons), create custom mappers:
import { Binding, inputBinding } from '@angular/core';
import { FieldTypeDefinition } from '@ng-forge/dynamic-forms';
import { ButtonField } from '@ng-forge/dynamic-forms/integration';
export function buttonFieldMapper(fieldDef: ButtonField<unknown, unknown>): Binding[] {
return [
inputBinding('key', () => fieldDef.key),
inputBinding('label', () => fieldDef.label),
inputBinding('disabled', () => fieldDef.disabled ?? false),
inputBinding('event', () => fieldDef.event),
inputBinding('props', () => fieldDef.props),
inputBinding('className', () => fieldDef.className),
];
}
export const CustomButtonType: FieldTypeDefinition = {
name: 'button',
loadComponent: () => import('./custom-button.component'),
mapper: buttonFieldMapper,
valueHandling: 'exclude', // Buttons don't contribute to form value
};Value Handling
The valueHandling property controls whether a field contributes to the form value:
'include'(default) - Field value included in form data'exclude'- Field excluded from form data (for buttons, text fields, etc.)
export const ButtonType: FieldTypeDefinition = {
name: 'button',
loadComponent: () => import('./button.component'),
mapper: buttonFieldMapper,
valueHandling: 'exclude', // Buttons don't have values
};Type Safety with Module Augmentation
Register your field types with TypeScript for full type inference:
// In your field types file
declare module '@ng-forge/dynamic-forms' {
interface FieldRegistryLeaves {
input: CustomInputField;
select: CustomSelectField;
checkbox: CustomCheckboxField;
}
}This enables:
- IntelliSense for field properties
- Type checking in form configurations
- Compile-time validation of field definitions
Handling Meta Attributes
meta attributes are native HTML attributes that should be applied to the underlying form element. They differ from props (which control UI library behavior). See Props vs Meta Summary below for detailed usage guidance.
Props vs Meta Summary
| Attribute Type | Example | Use props |
Use meta |
|---|---|---|---|
| UI appearance | appearance: 'outline' |
||
| Component behavior | multiple: true |
||
| Browser autofill | autocomplete: 'email' |
||
| Testing IDs | data-testid: 'email' |
||
| Accessibility | aria-describedby |
Using setupMetaTracking
ng-forge provides the setupMetaTracking utility to apply meta attributes to native elements. This uses Angular's afterRenderEffect for efficient DOM updates.
import { Component, ElementRef, inject, input } from '@angular/core';
import { FieldMeta } from '@ng-forge/dynamic-forms';
import { setupMetaTracking } from '@ng-forge/dynamic-forms/integration';
@Component({
template: ` <input [field]="f" /> `,
})
export default class CustomInputComponent {
private readonly elementRef = inject(ElementRef<HTMLElement>);
readonly meta = input<FieldMeta>();
constructor() {
// Apply meta attributes to the native input element
setupMetaTracking(this.elementRef, this.meta, { selector: 'input' });
}
}Note:
FieldMetais a core type exported from@ng-forge/dynamic-forms, whilesetupMetaTrackingis an integration utility exported from@ng-forge/dynamic-forms/integration. Both are also re-exported from the/integrationentrypoint for convenience, but the canonical import paths are as shown above.
Parameters:
elementRef: Reference to the host elementmeta: Signal containing the meta attributesoptions.selector: CSS selector to find the target element(s) within the host
Components with Dynamic Options
For components with dynamic options (radio groups, multi-checkbox), pass a dependents array to ensure meta updates when options change:
@Component({
template: `
@for (option of options(); track option.value) {
<label>
<input type="radio" [value]="option.value" />
{{ option.label }}
</label>
}
`,
})
export default class CustomRadioComponent {
private readonly elementRef = inject(ElementRef<HTMLElement>);
readonly meta = input<FieldMeta>();
readonly options = input<Option[]>([]);
constructor() {
// Re-apply meta when options change (new inputs are rendered)
setupMetaTracking(this.elementRef, this.meta, {
selector: 'input[type="radio"]',
dependents: [this.options],
});
}
}Shadow DOM Considerations
For components using Shadow DOM (like Ionic), you cannot access the internal input. Apply meta to the host element by omitting the selector:
@Component({
template: ` <ion-checkbox [checked]="value()"> {{ label() }} </ion-checkbox> `,
})
export default class IonicCheckboxComponent {
private readonly elementRef = inject(ElementRef<HTMLElement>);
readonly meta = input<FieldMeta>();
constructor() {
// No selector: applies meta to the host element itself
setupMetaTracking(this.elementRef, this.meta);
}
}Best Practices
Use proper component interfaces:
- Implement
ValueFieldComponentfor value fields - Implement
CheckedFieldComponentfor checkboxes/toggles - Define clear prop interfaces
Handle meta attributes:
- All components must accept a
metainput - Use
setupMetaTrackingwith a selector for native elements - Pass
dependentsfor components with dynamic options - Omit selector for Shadow DOM components (applies to host)
Leverage [field] binding:
- Use
[field]="f"on form controls - Automatic value and validation handling
- No manual form control management needed
Support DynamicText:
- Accept
DynamicTextfor labels, hints, placeholders - Use
DynamicTextPipefor rendering - Enables i18n with any translation library
Handle validation state:
- Show errors when
f().touched() && f().invalid() - Display validation messages from
f().errors() - Clear, accessible error presentation
Accessibility:
- Proper ARIA attributes
- Keyboard navigation
- Focus management
- Screen reader support
Lazy loading:
- Use dynamic imports in
loadComponent - Keeps initial bundle size small
- Components load on-demand
Reference Implementations
See complete integrations:
- Material Design - Full Material implementation with 12+ field types
- Bootstrap
- PrimeNG
- Ionic
The Material integration source code is the most comprehensive example of implementing custom field types.
Related Topics
- Material Integration - Complete reference implementation
- Field Types - Understanding all available field types
- Type Safety - Module augmentation for custom types
- Validation - Displaying validation errors in custom fields