Primitives
Toggle
A two-state button that stays pressed or unpressed.
Press it with the pointer, Space or Enter. The button reflects aria-pressed and data-state, so the pressed and unpressed looks come from one rule.
Two related primitives in a single folder: [forToggle] is a standalone two-state button (<button aria-pressed>); [forToggleGroup] + [forToggleGroupItem] compose a group of toggle buttons with single / multiple selection, roving tabindex, and arrow-key navigation. For exclusive selection where one option is always required, use [forRadioGroup] instead, or set deselectable="false" on a single-mode group. Radio guarantees one-of-N and moves the selection with the arrow keys, whereas ToggleGroup in single mode lets the user clear the selection unless deselectable is false, and its arrows move focus only.
Anatomy
<!-- Standalone two-state button -->
<button forToggle [(checked)]="bold">B</button>
<!-- Group of toggle buttons (single or multiple selection) -->
<div forToggleGroup [(value)]="format" multiple>
<button forToggleGroupItem value="bold">B</button>
<button forToggleGroupItem value="italic">I</button>
<button forToggleGroupItem value="underline">U</button>
</div>
Examples
States
One class and one directive, three states. Both disabled and readonly stay focusable (per APG). They reflect aria-disabled / data-disabled and aria-readonly / data-readonly rather than the native disabled attribute, so assistive tech still announces the button while interaction is a no-op.
ToggleGroup
A group of toggles with roving tabindex. In multiple mode each item toggles independently, and arrows only move focus: selection needs Space / Enter or click. value is always a readonly string[], carrying 0 or 1 entries in single mode, so flipping multiple needs no change to your state.
Signal Forms
ForToggleGroup implements FormValueControl<readonly string[]>, so [formField] binds the pressed-values array to a form field. This single-select alignment group is required: clearing the choice and blurring marks the group data-invalid / data-touched.
Signal Forms (single Toggle)
ForToggle implements FormCheckboxControl, so a single aria-pressed toggle (the natural home for bold / italic, mute, or favourite buttons) auto-wires with [formField] from @angular/forms/signals. The schema's disabled, readonly, required, invalid, pending, dirty, errors, and touched flow into the matching inputs without consumer glue.
import { Component, signal } from '@angular/core';
import { form, FormField, required } from '@angular/forms/signals';
import { ForToggle } from 'forty-cdk/toggle';
@Component({
selector: 'demo-bold',
imports: [ForToggle, FormField],
template: ` <button forToggle [formField]="prefs.bold" aria-label="Bold">B</button> `,
})
export class DemoBold {
readonly model = signal({ bold: false });
readonly prefs = form(this.model, (s) => required(s.bold));
}
When [name] is set (typically through [formField]), the directive mounts an <input type="hidden" value="on"> sibling while checked so the surrounding <form> picks it up during native submission.
For a set of related toggles, ForToggleGroup implements FormValueControl<readonly string[]> instead: its value is readonly string[], and a single-mode group simply carries [] or [selected].
Signal Forms (ToggleGroup)
ForToggleGroup implements FormValueControl<readonly string[]>, so it auto-wires with [formField] from @angular/forms/signals. The schema's disabled, readonly, required, invalid, pending, dirty, errors, touched, and name flow into the matching inputs without consumer glue.
import { Component, signal } from '@angular/core';
import { form, required, requiredError, validate } from '@angular/forms/signals';
import { ForToggleGroup, ForToggleGroupItem } from 'forty-cdk/toggle';
@Component({
selector: 'demo-formats',
imports: [ForToggleGroup, ForToggleGroupItem /* , FormField from @angular/forms */],
template: `
<div forToggleGroup multiple [formField]="prefs.formats" aria-label="Formats">
<button forToggleGroupItem value="bold">B</button>
<button forToggleGroupItem value="italic">I</button>
<button forToggleGroupItem value="underline">U</button>
</div>
`,
})
export class DemoFormats {
readonly model = signal({ formats: [] as string[] });
readonly prefs = form(this.model, (s) => {
required(s.formats);
validate(s.formats, ({ value }) =>
value().length === 0 ? requiredError({ message: 'Pick at least one format' }) : undefined,
);
});
}
Requiring a non-empty selection.
ForToggleGroup's value is areadonly string[], and Angular'srequired()treats only'',false,null, andNaNas empty. An empty array[]counts as present, sorequired(s.formats)reflectsdata-requiredbut never makes the form invalid on its own. Enforce "at least one" with the explicitvalidate(...)length rule above, or with Angular'sminLength(s.formats, 1)(which emits aminLengthErrorinstead of arequiredError). The single-booleanForToggleabove is unaffected:required(s.bold)works becausefalseis treated as empty.
When [name] is set (typically through [formField]), the directive mounts one <input type="hidden"> sibling per selected value so the surrounding <form> picks the values up during native submission.
Choose between the two form-control shapes by the value you need: ForToggle (FormCheckboxControl, a single boolean) for one independent on/off button, ForToggleGroup (FormValueControl<readonly string[]>) for a set of related toggles whose selection is a list.
API
ForToggle
| Property | Type | Description |
|---|---|---|
checked | model | Two-way bindable on/off state. Required by FormCheckboxControl; what [formField] binds. The host reflects it via aria-pressed and data-state.Default: false |
disabled | input | When true, click is ignored; reflects aria-disabled="true" + data-disabled. Stays focusable (per APG) and sets no native disabled.Default: false |
readonly | input | When true, click is ignored but the host stays focusable. Reflected as data-readonly only, because aria-readonly is not supported on role="button".Default: false |
required | input | Reflected as data-required only, because aria-required is not supported on role="button".Default: false |
invalid | input | Reflected as aria-invalid and data-invalid.Default: false |
pending | input | Reflected as aria-busy and data-pending.Default: false |
dirty | input | Reflected as data-dirty.Default: false |
name | input | When non-empty, a hidden input is mounted for native form submission (name=on while checked).Default: '' |
errors | input | Validation errors surfaced by Signal Forms. Default: [] |
touched | model | Two-way bindable. Set to true on blur.Default: false |
| Data attribute | Values | |
|---|---|---|
data-state | checked | unchecked | |
data-disabled | present | absent | |
data-readonly | present | absent | |
data-required | present | absent |
ForToggleGroup
| Property | Type | Description |
|---|---|---|
value | model | Two-way bindable. Selected values, in arbitrary order. Required by FormValueControl<readonly string[]>.Default: [] |
multiple | input | When true, items toggle independently. When false, single mode (clicking the pressed item clears, unless deselectable is false).Default: false |
deselectable | input | In single mode, whether pressing the pressed item clears the selection. When false, that press is a no-op and the item keeps aria-pressed="true"; a group with no value still starts empty. Ignored in multiple mode. The default is read from provideForToggleDefaults.Default: true |
disabled | input | Disables every item regardless of per-item state. Default: false |
readonly | input | Click is ignored, items remain focusable. Reflected as data-readonly on the root only, because aria-readonly is supported on neither role="group" nor the items' role="button".Default: false |
required | input | Reflected as data-required on the root only, because aria-required is supported on neither role="group" nor the items' role="button".Default: false |
invalid | input | Reflected as aria-invalid and data-invalid.Default: false |
pending | input | Reflected as aria-busy and data-pending.Default: false |
dirty | input | Reflected as data-dirty.Default: false |
name | input | When non-empty, hidden inputs are mounted for native form submission (one per selected value). Default: '' |
errors | input | Validation errors surfaced by Signal Forms. Default: [] |
touched | model | Two-way bindable. Set on focusout outside the group. Default: false |
orientation | input | Layout direction for keyboard navigation. Default: 'horizontal' |
dir | input | Reading direction. RTL swaps ArrowLeft / ArrowRight. Default: 'ltr' |
loop | input | When true, arrow nav wraps at the ends. The default is read from provideForToggleDefaults.Default: true |
| Data attribute | Values | |
|---|---|---|
data-orientation | horizontal | vertical | |
data-disabled | present | absent | |
data-readonly | present | absent |
ForToggleGroupItem
| Property | Type | Description |
|---|---|---|
value | input.required | Identifier added to / removed from the group's value.Default: — |
disabled | input | Per-item disabled, in addition to the group's disabled.Default: false |
| Data attribute | Values | |
|---|---|---|
data-state | checked | unchecked | |
data-disabled | present | absent | |
data-orientation | horizontal | vertical |
Keyboard
- Enter / Space on the focused item toggles it (native button behavior).
- ArrowLeft / ArrowRight moves focus in horizontal mode (or vertical, swapped via
orientation). Disabled items are skipped. - ArrowUp / ArrowDown moves focus in vertical mode.
- Home / End jump to the first / last enabled item.
- Tab enters and exits the group at the entry-point item. Before any interaction that is the first selected item, or the first enabled item when no value is selected; once you move focus with the arrows (or Home / End), the tab stop follows the last focused item, so Shift+Tab back into the group restores it.
Arrow keys move focus only. Selection requires an explicit click or Space / Enter. There is no selection-on-focus, unlike [forRadioGroup].
Accessibility
Implements the WAI-ARIA Button pattern (toggle-button variant).
[forToggle]emitsrole="button"witharia-pressed="true|false", which screen readers announce as "toggle button".[forToggleGroup]emitsrole="group"and reflectsdata-orientationfor CSS (notaria-orientation). Provide a label viaaria-labeloraria-labelledby.- Roving tabindex manages focus within the group. The consumer never sets
tabindexmanually. - A disabled item stays focusable (per APG): it reflects
aria-disabled="true"+data-disabled=""rather than nativedisabled, so assistive tech still announces it while interaction is a no-op. readonlyhas no ARIA channel here. WAI-ARIA supportsaria-readonlyon neitherrole="button"norrole="group", so a read-only toggle / group reflects the booleandata-readonlystyling hook only and interaction is blocked in the handler.
Styling
forty-cdk ships no styles: put your own class on each piece and key your CSS off the data-* attributes listed under API, not off the for* selectors (Styling forty-cdk explains why).
.toggle {
background: var(--surface);
}
.toggle[data-state='checked'] {
background: var(--accent);
}
.toggle[data-disabled] {
opacity: 0.5;
cursor: not-allowed;
}
Behavior notes
data-stateuses the form-control vocabulary"checked" | "unchecked"(perCLAUDE.mdcross-primitive convention), even though ARIA usesaria-pressed. The data attribute mirrors the logical "is this option active" state, not the ARIA term.- Single mode lets the user reach the
[]state by clicking the currently pressed item again. Setdeselectable="false"to keep the pressed item pressed instead, for a segmented button whose arrows move focus only, or use[forRadioGroup]if you need to enforce one-of-N with the selection following the arrow keys. - Roving tabindex follows focus: once any item is focused, that item becomes the tab stop so re-entry restores it. Before any focus, the entry point is computed from the group's value: the first selected item when there is at least one selection, otherwise the first enabled item in DOM order. The consumer never sets
tabindexmanually.
Wrapping in a design system
Wrapping form primitives documents both supported wrapper patterns: hostDirectives with the exported FOR_TOGGLE_HOST_DIRECTIVE_INPUTS / FOR_TOGGLE_HOST_DIRECTIVE_OUTPUTS and FOR_TOGGLE_GROUP_HOST_DIRECTIVE_INPUTS / FOR_TOGGLE_GROUP_HOST_DIRECTIVE_OUTPUTS name tuples, and subclassing.