Custom Form Controls in Angular: ControlValueAccessor and Signal Forms [2026]
Sooner or later a form needs an input the browser doesn't have: a star rating, a toggle switch, a colour picker, a tag selector. Building the UI is the easy part. The hard part is making it behave like a real form control: it should show the form's current value, report changes, become touched on blur, respect the disabled state and take part in validation. Angular has two ways to do that: the long-standing ControlValueAccessor interface for reactive and template-driven forms, and a much smaller, signal-based contract for Signal Forms.
This is lesson 14.4 of the Angular Tutorial. It follows lesson 14.3 on NgRx Store and builds on the forms module: Signal Forms (4.1), reactive forms (4.2) and template-driven forms (4.3). You'll build a star-rating control with ControlValueAccessor, add built-in validation with NG_VALIDATORS, then build the same control the modern way with FormValueControl, and see how the two worlds interoperate.
Two contracts for the same job #
A form directive (formControlName, ngModel or [formField]) needs a way to talk to your component in both directions:
| Concern | ControlValueAccessor |
Signal Forms (FormValueControl) |
|---|---|---|
| Form → control: new value | writeValue(value) |
value model signal is set |
| Control → form: user changed it | Call the function from registerOnChange |
value.set(...) |
| Touched | Call the function from registerOnTouched |
Emit a touch output |
| Disabled | setDisabledState(isDisabled) |
disabled input signal |
| Validation | Optional Validator + NG_VALIDATORS |
Rules live in the form schema; control reads invalid / errors inputs |
| Registration | NG_VALUE_ACCESSOR provider with forwardRef |
None: implement the interface |
| Works with | Reactive and template-driven forms; [formField] for backwards compatibility |
Signal Forms, and as-is with reactive and template-driven forms |
Building a star rating with ControlValueAccessor #
The component keeps its own state in signals and implements the four methods:
import { Component, forwardRef, signal } from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
@Component({
selector: 'app-star-rating',
template: `
<div role="radiogroup" aria-label="Rating" (focusout)="onTouched()">
@for (star of stars; track star) {
<button
type="button"
role="radio"
[attr.aria-checked]="star === value()"
[attr.aria-label]="star + ' star' + (star > 1 ? 's' : '')"
[class.filled]="star <= value()"
[disabled]="disabled()"
(click)="select(star)">
★
</button>
}
</div>
`,
providers: [
{ provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => StarRating), multi: true },
],
})
export class StarRating implements ControlValueAccessor {
protected readonly stars = [1, 2, 3, 4, 5];
protected readonly value = signal(0);
protected readonly disabled = signal(false);
private onChange: (value: number) => void = () => {};
protected onTouched: () => void = () => {};
// form → control
writeValue(value: number | null): void {
this.value.set(value ?? 0);
}
// the form gives us callbacks to call later
registerOnChange(fn: (value: number) => void): void {
this.onChange = fn;
}
registerOnTouched(fn: () => void): void {
this.onTouched = fn;
}
setDisabledState(isDisabled: boolean): void {
this.disabled.set(isDisabled);
}
// control → form
protected select(star: number): void {
this.value.set(star);
this.onChange(star);
}
}
What each piece does:
NG_VALUE_ACCESSORis howformControlNameandngModelfind your component.multi: trueis required because it's a multi-provider token, andforwardRefis needed because the class is referenced in its own decorator, before it is defined.writeValueis called with the initial value and on every programmatic change (setValue,reset,patchValue). It must not callonChange; otherwise areset()would mark the control dirty. Handlenull, sincereset()passes it.registerOnChange/registerOnTouchedhand you callbacks. CallonChangeonly for user-driven changes, andonTouchedwhen the user leaves the control (here,focusouton the group).setDisabledStateis optional in the interface, but implement it: without it,control.disable()does nothing visible.
Because the component uses signals for its internal state, it updates correctly under OnPush and zoneless change detection, which are the defaults in v22. A plain field set inside writeValue would not refresh the view.
The same control now works in both classic form styles:
<!-- reactive (lesson 4.2) -->
<form [formGroup]="reviewForm">
<app-star-rating formControlName="rating" />
</form>
<!-- template-driven (lesson 4.3) -->
<app-star-rating [(ngModel)]="rating" name="rating" />
Adding built-in validation with NG_VALIDATORS #
Sometimes the control itself knows what's valid. A rating of 0 means "not rated", so the control can report that without every form adding a validator. Implement Validator and register it with NG_VALIDATORS:
import {
AbstractControl, ControlValueAccessor, NG_VALIDATORS, NG_VALUE_ACCESSOR,
ValidationErrors, Validator,
} from '@angular/forms';
@Component({
selector: 'app-star-rating',
// template unchanged
providers: [
{ provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => StarRating), multi: true },
{ provide: NG_VALIDATORS, useExisting: forwardRef(() => StarRating), multi: true },
],
})
export class StarRating implements ControlValueAccessor, Validator {
// ...ControlValueAccessor members as before
validate(control: AbstractControl<number | null>): ValidationErrors | null {
const v = control.value ?? 0;
return v >= 1 && v <= 5 ? null : { rating: { min: 1, max: 5, actual: v } };
}
}
Angular combines this validator with any validators the form adds, so reviewForm.controls.rating.errors can contain both required and rating. If your validator depends on component inputs that can change, also implement registerOnValidatorChange and call the function it gives you when they do.
The modern path: Signal Forms controls #
Signal Forms (lesson 4.1, stable in v22) replace the whole accessor contract with signals. A value control implements FormValueControl<T> and exposes a value model signal; that's the only required member:
import { Component, input, model, output } from '@angular/core';
import { FormValueControl } from '@angular/forms/signals';
@Component({
selector: 'app-star-rating',
template: `
<div role="radiogroup" aria-label="Rating" [class.invalid]="invalid()" (focusout)="touch.emit()">
@for (star of stars; track star) {
<button
type="button"
role="radio"
[attr.aria-checked]="star === value()"
[attr.aria-label]="star + ' star' + (star > 1 ? 's' : '')"
[class.filled]="star <= value()"
[disabled]="disabled()"
(click)="value.set(star)">
★
</button>
}
</div>
`,
})
export class StarRating implements FormValueControl<number> {
protected readonly stars = [1, 2, 3, 4, 5];
// required: two-way bound to the field's value
readonly value = model(0);
// optional: bound automatically from the field's state
readonly disabled = input(false);
readonly invalid = input(false);
// optional: tells the form the user has touched the control
readonly touch = output<void>();
}
No providers, no callbacks, no writeValue. When you bind the control with the FormField directive, it binds the field's value to value, passes state such as disabled, invalid, errors, touched and required into any matching inputs you declare, and listens for touch:
import { Component, signal } from '@angular/core';
import { form, FormField, min, required } from '@angular/forms/signals';
@Component({
selector: 'app-review-form',
imports: [FormField, StarRating],
template: `
<form novalidate>
<app-star-rating [formField]="reviewForm.rating" />
@if (reviewForm.rating().touched() && reviewForm.rating().invalid()) {
<p class="error">Please choose a rating.</p>
}
<textarea [formField]="reviewForm.comment"></textarea>
</form>
`,
})
export class ReviewForm {
reviewModel = signal({ rating: 0, comment: '' });
reviewForm = form(this.reviewModel, s => {
min(s.rating, 1, { message: 'Please choose a rating' });
required(s.comment);
});
}
Notice where validation lives. In Signal Forms, the control displays validation state but doesn't decide it: rules go in the form schema. To ship a control together with its usual rules, export a companion schema with schema() and let consumers add it with apply(s.rating, ratingSchema).
For a boolean control such as a toggle switch, implement FormCheckboxControl instead: it requires a checked model signal and must not have a value property. If a control displays a value in a different format than it stores (a formatted currency string for a number, say), transformedValue() from @angular/forms/signals keeps the two in sync and can report parse errors to the field.
Keyboard and screen-reader behaviour #
A custom control replaces a native element, so it also has to replace what the native element did for free. Both versions above use real <button> elements, which gives you focus, Enter and Space activation and a disabled state without extra code. They also expose the group as a radiogroup with aria-checked on each star, so a screen reader announces "3 stars, radio button, checked" rather than a row of identical symbols.
A complete radio group goes one step further: only the selected star is in the tab order, and the arrow keys move the selection. You can add that with a (keydown) handler and a roving tabindex, or build the control on top of a headless listbox or radio pattern from Angular Aria (lesson 14.5), which implements the keyboard model for you. Whichever you choose, connect the control to a visible label, either by wrapping it in a <label> or with aria-labelledby, and make sure validation messages are announced, for example with role="alert" on the error text.
Test the control the way users will use it: tab to it, change the value with the keyboard only, tab away and check that the field became touched, then repeat with a screen reader running.
Interoperability and migration #
The two worlds connect in both directions, which makes gradual migration practical:
| You have | Used in | Works? |
|---|---|---|
FormValueControl / FormCheckboxControl component |
Signal Forms with [formField] |
Yes, the native path |
FormValueControl / FormCheckboxControl component |
Reactive (formControlName) or template-driven (ngModel) |
Yes, as-is, with no adapter code |
ControlValueAccessor component |
Reactive or template-driven forms | Yes |
ControlValueAccessor component |
Signal Forms with [formField] |
Yes, supported for backwards compatibility |
So for a shared component library, new controls can be written as FormValueControl and still serve teams on reactive forms. Existing accessors can be converted one at a time. The Angular docs are explicit about one rule: don't implement both ControlValueAccessor and FormValueControl on the same component; pick one.
Gotchas #
| Symptom | Cause | Fix |
|---|---|---|
No value accessor for form control name: 'rating' |
NG_VALUE_ACCESSOR provider missing or misspelled, or component not imported |
Add the provider with multi: true and forwardRef; import the component |
Form is dirty right after reset() |
writeValue calls onChange |
Only call onChange from user interactions |
Control shows a stale value after setValue() |
Value stored in a plain field under OnPush / zoneless |
Store internal state in a signal |
control.disable() has no visible effect |
setDisabledState not implemented (CVA) or no disabled input (Signal Forms) |
Implement it and bind [disabled] in the template |
| Field is never marked touched | onTouched never called, or touch never emitted |
Call or emit on blur / focusout, not on focus |
Type errors or unpredictable binding with [formField] |
Component has both value and checked, or implements both CVA and FormValueControl |
Use value for value controls, checked for checkbox controls; implement one contract |
What's next #
Lesson 14.5 looks at Angular Material and the CDK: setting up Material 3 theming with mat.theme(), design tokens and dark mode, the CDK's unstyled building blocks, and how they relate to Angular Aria. Material's form fields are also a good place to see a polished custom control in practice.
Try it yourself #
Find a custom control in your codebase that implements ControlValueAccessor. Check writeValue for onChange calls, confirm setDisabledState is implemented and that internal state is held in signals. Then write a FormValueControl version beside it and use it in an existing reactive form to confirm it works unchanged.
search_documentationNo. Your existing accessors already work in Signal Forms, because [formField] supports ControlValueAccessor components for backwards compatibility, so nothing breaks on day one. For the long term, migrate each control to FormValueControl (or FormCheckboxControl for toggles and checkboxes). A control written that way also works with formControlName and ngModel without adapter code, so one implementation serves both groups of apps. Convert one control at a time: replace writeValue and the registered callbacks with a value model signal, setDisabledState with a disabled input, and onTouched with a touch output, then remove the providers. Don’t implement both contracts on the same class. Move any NG_VALIDATORS logic into an exported companion schema that Signal Forms users can apply(), and export the same rule as a plain validator function for reactive forms users to add to their FormControl.Up next in Angular
More from this topic
Enjoyed this article?
Get new Angular tutorials delivered. No spam — just code-first articles when they ship.


