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 the field input. You only need to declare it explicitly for custom mappers that supply other reactive inputs, or to opt out with renderReadyWhen: [] if your component doesn't need field.

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 forms
  • key: string - Unique field identifier
  • label?: DynamicText - Field label
  • placeholder?: DynamicText - Placeholder text
  • className?: string - CSS classes
  • tabIndex?: number - Tab order
  • props?: TProps - Custom field-specific props
  • meta?: FieldMeta - Native HTML attributes (data-, aria-, autocomplete, etc.)

Important: The meta input, 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 like field, declare the corresponding readiness contract on the FieldTypeDefinition (for example renderReadyWhen: ['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: FieldMeta is a core type exported from @ng-forge/dynamic-forms, while setupMetaTracking is an integration utility exported from @ng-forge/dynamic-forms/integration. Both are also re-exported from the /integration entrypoint for convenience, but the canonical import paths are as shown above.

Parameters:

  • elementRef: Reference to the host element
  • meta: Signal containing the meta attributes
  • options.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 ValueFieldComponent for value fields
  • Implement CheckedFieldComponent for checkboxes/toggles
  • Define clear prop interfaces

Handle meta attributes:

  • All components must accept a meta input
  • Use setupMetaTracking with a selector for native elements
  • Pass dependents for 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 DynamicText for labels, hints, placeholders
  • Use DynamicTextPipe for 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:

The Material integration source code is the most comprehensive example of implementing custom field types.