An Angular form field pattern that scales
Form fields are deceptively complex. Each field carries multiple concerns: label layout, validation UI, error timing, accessibility wiring, and focus behavior. On top of that, every field still needs uniform styling so the experience stays visually identical across the app. And then it must support a wide range of inputs: eg. text, number, datepicker, or more custom input like color picker and compound controls.
So why not split this into two parts and let each handle its own responsibilities while implementing a shared contract?
- A reusable field shell component
- 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 require.
The core idea
The shell owns structure and shared behavior:
- Label, hint, and error regions
- Prefix/suffix slots
- Required marker
- Validation message switching (
hintvserror) - Accessibility wiring (
for,aria-describedby) - Container click to focus delegation
- Visual states and variants (any style variant you need, eg. size, appearance, etc.)
The projected control owns interaction:
- Value entry and value model
- How focus is handled internally
- Whether it participates in Angular forms
The integration boundary is a contract (interface + injection token).
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;
}
Keep the contract small. Small contracts are easier to implement correctly across many controls.
If this feels familiar
If this pattern sounds familiar, you are probably thinking of Material Angular form fields.
Material uses the same 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:1Angular Material kept this split stable for years because the shell API stays integration-focused instead of value-model specific. 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 value input itself. They are usually about surrounding behavior:
- Validation timing
- Error visibility policy
- Label/description wiring
- Focus affordances
- State styling and spacing
Putting those rules in one shell gives every control the same baseline UX by default.
2) You avoid component explosion
Without a contract, teams often build one component per field type (TextField, PhoneField, DateField, ...), each repeating similar wrapper logic.
With a contract, you can keep one shell and many controls.
That reduces maintenance and design drift over time.
3) You preserve local freedom where it matters
Each control remains free to model its own interaction: masking, parsing, keyboard handling, composite values, async lookups, etc. The shell does not constrain those details.
In short: standardize the frame, not the input mechanics.
How to implement it
1) Define the control contract
The shell needs only what it cannot infer safely:
- Host element reference (focus + IDs)
- Form state handle (
NgControl | null) - State flags (
disabled,readonly,required) - Two behavioral hooks (
setDescribedByIds,onContainerClick)
Avoid adding 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,
computed,
contentChild,
effect,
} from '@angular/core';
import { FORM_FIELD_CONTROL, FormFieldControl } from './form-field-control';
@Component({
selector: 'ui-form-field',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<label class="ui-form-field__label">
<ng-content select="ui-label"></ng-content>
@if (control()?.required) {
<span aria-hidden="true">*</span>
}
</label>
<div class="ui-form-field__control" (click)="onContainerClick($event)">
<ng-content></ng-content> // FormFieldControl gets projected here
</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 {
private static nextId = 0;
private readonly instanceId = UiFormFieldComponent.nextId++;
readonly control = contentChild<FormFieldControl>(FORM_FIELD_CONTROL);
readonly hintId = `ui-form-field-hint-${this.instanceId}`;
readonly errorId = `ui-form-field-error-${this.instanceId}`;
readonly showError = computed(() => {
const ngControl = this.control()?.ngControl;
return !!ngControl && ngControl.invalid && (ngControl.touched || ngControl.dirty);
});
constructor() {
effect(() => {
const control = this.control();
if (!control) return;
control.setDescribedByIds([this.showError() ? this.errorId : this.hintId]);
});
}
onContainerClick(): void {
this.control()?.onContainerClick();
}
}
3) Provide a bridge for native inputs
Create a directive for input/textarea that implements the contract.
This gives immediate adoption for common controls without extra wrappers.
import { Directive, ElementRef, InjectionToken, inject } from '@angular/core';
import { NgControl } 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 {
return this.elementRef.nativeElement.required;
}
setDescribedByIds(ids: string[]): void {
this.elementRef.nativeElement.setAttribute('aria-describedby', ids.join(' '));
}
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
For a phone input, date range picker, or composite value control:
- Implement the contract
- Provide the injection token
- Expose
NgControlwhen integrated with Angular forms
No changes are required in the shell.
Practical guidance
- Treat the shell as 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. It scales because consistency is centralized, while control behavior remains extensible.
If you really need a self-contained field component
Composition is still the default recommendation, but this API also supports encapsulation when you need it.
You can wrap ui-form-field and the projected input inside a single reusable component without breaking the architecture.
import {
ChangeDetectionStrategy,
Component,
booleanAttribute,
computed,
input,
} from '@angular/core';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
@Component({
selector: 'app-email-field',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ReactiveFormsModule, UiFormFieldComponent, UiTextInputDirective],
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 (showError()) {
<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 });
readonly showError = computed(() => {
const control = this.control();
return control.invalid && (control.dirty || control.touched);
});
}
Usage stays simple:
<app-email-field
[control]="emailControl"
label="Work email"
hint="We only use this for account notifications."
/>
That gives you a fully encapsulated field where needed, while still reusing the same form field shell contract and behavior as the rest of the system.