An Angular form field pattern that scales

Form fields look simple, right up until they are not. One minute it is "just an input," and the next you are juggling labels, validation states, error timing, accessibility wiring, and focus behavior. On top of that, every field should look consistent across the app while still supporting very different controls: text, number, datepicker, color picker, or compound inputs.

The pattern that has worked well for me is to split the field into two parts and let each one do one job well, connected by a small shared contract.

  1. A reusable field shell component
  2. A projected control that implements a small contract

This way, every input uses the same shell, while the projected control can be any kind of form control you need.

The core idea

The shell is a resuable wrapper that handles:

  • Label, hint, and error regions
  • Prefix/suffix slots
  • Required marker
  • Validation message switching (hint vs error)
  • Accessibility wiring (for, aria-describedby)
  • Container click to focus delegation
  • Visual states and variants (any style variant you need, e.g. size, appearance, etc.)

The projected control handles the actual hands-on interaction:

  • Value entry and value model
  • How focus is handled internally
  • Whether it participates in Angular forms

The handshake between the two is a small contract (interface + injection token). Keep the contract tiny. Tiny contracts are easier to implement correctly, test quickly, and reuse across many controls.

If this feels familiar ...

If this pattern is feeling familiar, you are probably thinking of Material Angular form fields.

Material Angular uses this exact core architecture: a field shell (mat-form-field) paired with controls that implement a contract (MatFormFieldControl). The example in this post is intentionally simplified, but the design principle is the same:1This split has held up in Angular Material for years because the shell only coordinates shared concerns (labels, hints, errors, a11y), while each control keeps its own value logic. That boundary keeps the shell stable even as new control types are added. Neat! Back

  • The shell owns shared field behavior and presentation.
  • The control owns input interaction details.
  • A contract keeps both sides decoupled and reusable.

Why this works

1) You centralize hard-to-get-right behavior once

Form consistency problems are rarely about entering the value itself. They usually come from everything around it:

  • Validation timing
  • Error visibility policy
  • Label/description wiring
  • Focus indicators
  • State styling and spacing

Put those rules in one shell, and every control gets the same baseline UX out of the box.

2) You avoid component explosion

Without a contract, teams often end up with one component per field type (TextField, PhoneField, DateField, ...), each repeating nearly identical wrapper logic. With a contract, you keep one shell and many controls.

That means less maintenance and far less "why do these two fields look different?" over time.

3) You preserve local freedom where it matters

Each control stays free to model its own interaction: masking, parsing, keyboard handling, composite values, async lookups, and so on. The shell sets the frame, then gets out of the way.

In short: standardize the frame, not the input mechanics.

How to implement it

1) Define the control contract

The shell should ask for only what it cannot safely infer:

  • Host element reference (focus + IDs)
  • Form state handle (NgControl | null)
  • State flags (disabled, readonly, required)
  • Two behavioral hooks (setDescribedByIds, onContainerClick)
export interface FormFieldControl {
  readonly elementRef: ElementRef<HTMLElement>;
  readonly ngControl: NgControl | null;
  readonly disabled: boolean;
  readonly readonly: boolean;
  readonly required: boolean;

  setDescribedByIds(ids: string[]): void;
  onContainerClick(): void;
}

Resist the urge to add value APIs to this contract unless absolutely necessary. Keep it integration-focused, not control-specific.

2) Build the shell around projected content

The shell should:

  • Render semantic regions (label, control, hint, error, optional prefix/suffix)
  • Decide when hint vs error is shown (for example: invalid && (touched || dirty))
  • Wire accessibility IDs to the control (aria-describedby)
  • Delegate container click to onContainerClick()
import {
  Component,
  ChangeDetectionStrategy,
  DoCheck,
  computed,
  contentChild,
} from '@angular/core';
import { FORM_FIELD_CONTROL, FormFieldControl } from './form-field-control';
import { UiHintDirective } from './ui-hint.directive';
import { UiErrorDirective } from './ui-error.directive';

@Component({
  selector: 'ui-form-field',
  standalone: true,
  template: `
    <label class="ui-form-field__label" [attr.for]="controlId()">
      <ng-content select="ui-label"></ng-content>
      @if (control()?.required) {
        <span aria-hidden="true">*</span>
      }
    </label>

    <div class="ui-form-field__control" (click)="onContainerClick()">
      <ng-content></ng-content>
    </div>

    @if (showError()) {
      <div class="ui-form-field__error" [id]="errorId()">
        <ng-content select="ui-error"></ng-content>
      </div>
    } @else {
      <div class="ui-form-field__hint" [id]="hintId()">
        <ng-content select="ui-hint"></ng-content>
      </div>
    }
  `,
})
export class UiFormFieldComponent implements DoCheck {
  private static nextId = 0;

  readonly control = contentChild<FormFieldControl>(FORM_FIELD_CONTROL);
  readonly hint = contentChild(UiHintDirective);
  readonly error = contentChild(UiErrorDirective);

  readonly controlId = computed(() => {
    const control = this.control();
    if (!control) return null;

    const element = control.elementRef.nativeElement;
    if (!element.id) {
      element.id = `ui-form-field-control-${UiFormFieldComponent.nextId++}`;
    }

    return element.id;
  });

  readonly hintId = computed(() => {
    const id = this.controlId();
    return id ? `${id}-hint` : null;
  });

  readonly errorId = computed(() => {
    const id = this.controlId();
    return id ? `${id}-error` : null;
  });

  private lastDescribedBy = '__ui_uninitialized__';

  showError(): boolean {
    const ngControl = this.control()?.ngControl;
    return !!ngControl && ngControl.invalid && (ngControl.touched || ngControl.dirty);
  }

  ngDoCheck(): void {
    const control = this.control();
    if (!control) return;

    const ids: string[] = [];
    const hintId = this.hintId();
    const errorId = this.errorId();

    if (this.showError()) {
      if (this.error() && errorId) ids.push(errorId);
    } else {
      if (this.hint() && hintId) ids.push(hintId);
    }

    const describedBy = ids.join(' ');
    if (describedBy === this.lastDescribedBy) return;

    this.lastDescribedBy = describedBy;
    control.setDescribedByIds(ids);
  }

  onContainerClick(): void {
    this.control()?.onContainerClick();
  }
}

3) Provide a bridge for native inputs

Start with a directive for input/textarea that implements the contract. That gives you immediate coverage for common controls without extra wrappers, which is a quick win.

import { Directive, ElementRef, InjectionToken, inject } from '@angular/core';
import { NgControl, Validators } from '@angular/forms';

export const FORM_FIELD_CONTROL =
  new InjectionToken<FormFieldControl>('FORM_FIELD_CONTROL');

@Directive({
  selector: 'input[uiTextInput], textarea[uiTextInput]',
  standalone: true,
  providers: [
    { provide: FORM_FIELD_CONTROL, useExisting: UiTextInputDirective },
  ],
})
export class UiTextInputDirective implements FormFieldControl {
  readonly elementRef = inject(
    ElementRef<HTMLInputElement | HTMLTextAreaElement>,
  );
  readonly ngControl = inject(NgControl, { self: true, optional: true });

  get disabled(): boolean {
    return this.ngControl?.disabled ?? this.elementRef.nativeElement.disabled;
  }

  get readonly(): boolean {
    return this.elementRef.nativeElement.readOnly;
  }

  get required(): boolean {
    const elementRequired = this.elementRef.nativeElement.required;
    const validatorRequired = !!this.ngControl?.control?.hasValidator?.(Validators.required);
    return elementRequired || validatorRequired;
  }

  setDescribedByIds(ids: string[]): void {
    const element = this.elementRef.nativeElement;
    if (ids.length) {
      element.setAttribute('aria-describedby', ids.join(' '));
    } else {
      element.removeAttribute('aria-describedby');
    }
  }

  onContainerClick(): void {
    this.elementRef.nativeElement.focus();
  }
}
<ui-form-field>
  <ui-label>Email</ui-label>
  <input uiTextInput [formControl]="emailControl" required />
  <ui-hint>We only use this for account notifications.</ui-hint>
  <ui-error>Enter a valid email before continuing.</ui-error>
</ui-form-field>

4) Add custom controls by implementing the same contract

When you need custom controls like a phone input, date range picker, composite value control, etc:

  • Implement the contract
  • Provide the injection token
  • Expose NgControl when integrated with Angular forms

No changes to the shell needed, which is exactly the point.

Practical guidance

  • Treat the shell as shared infrastructure. Changes there affect every field.
  • Keep validation policy explicit and documented.
  • Write one contract-conformance test for each custom control.
  • Prefer composition over wrappers unless the wrapper adds real domain value.

The result is a form system that stays coherent as your control catalog grows. Consistency stays centralized, while control behavior stays extensible.

If you want a self-contained field component

At its core, this pattern is composition first: shell, label, control, hint, and error are separate pieces connected by one tiny contract. That separation is where the flexibility comes from. Need to swap one piece later? You can, without rebuilding the whole field.

When you want a cleaner call site, wrap the Form field in a component. You can bundle ui-form-field and the projected input into one reusable component while keeping the same underlying architecture.

import {
  ChangeDetectionStrategy,
  Component,
  booleanAttribute,
  input,
} from '@angular/core';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { UiFormFieldComponent } from './ui-form-field.component';
import { UiTextInputDirective } from './ui-text-input.directive';
import { UiLabelDirective } from './ui-label.directive';
import { UiHintDirective } from './ui-hint.directive';
import { UiErrorDirective } from './ui-error.directive';

@Component({
  selector: 'app-email-field',
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  imports: [
    ReactiveFormsModule,
    UiFormFieldComponent,
    UiTextInputDirective,
    UiLabelDirective,
    UiHintDirective,
    UiErrorDirective,
  ],
  template: `
    <ui-form-field>
      <ui-label>{{ label() }}</ui-label>
      <input
        uiTextInput
        type="email"
        [formControl]="control()"
        [required]="isRequired()"
        [readonly]="isReadonly()"
      />
      @if (hint()) {
        <ui-hint>{{ hint() }}</ui-hint>
      }
      @if (errorText()) {
        <ui-error>{{ errorText() }}</ui-error>
      }
    </ui-form-field>
  `,
})
export class EmailFieldComponent {
  readonly control = input.required<FormControl<string | null>>();
  readonly label = input('Email');
  readonly hint = input('');
  readonly errorText = input('Enter a valid email.');
  readonly isRequired = input(false, { transform: booleanAttribute });
  readonly isReadonly = input(false, { transform: booleanAttribute });
}

Usage stays simple:

<app-email-field
  [control]="emailControl"
  label="Work email"
  hint="We only use this for account notifications."
/>

That gives you a self-contained field when you want one, while still reusing the same contract and shell behavior as everything else. Composition stays the engine; wrappers are just ergonomic sugar on top.

And as a bonus you can implement Angular's Control Value Accessor into the wrapper component, making it play nice with both reactive- and template-driven forms.

Back to all posts