On, off, and out of reach
A switch takes effect at once — there is no Save behind it — so a disabled one says "this setting is not yours here" rather than "fill this in later".
A setting that takes effect the moment it is flipped, with no Save button beside it.
@pacit/components/switchpct-switchImplements the W3C ARIA APG Switch pattern ↗
import { Component, signal } from '@angular/core';
import { PctSwitch } from '@pacit/components/switch';
/** On or off, effective immediately — that immediacy is what separates it from a checkbox. */
@Component({
selector: 'demo-switch',
imports: [PctSwitch],
template: `
<pct-switch
label="Wi-Fi"
hint="Turns off when you leave the house"
[(checked)]="wifi"
/>
`,
})
export class SwitchDemo {
readonly wifi = signal(true);
}switch.demo.ts — the same file runs here and is highlighted under Code
One import per component, and the shortest possible use — the package itself is installed once, on the start page.
import { PctSwitch } from '@pacit/components/switch';| Exported | What it is |
|---|---|
PctSwitchcomponentpct-switch | Switch. A setting that takes effect the moment it is moved, drawn as a track the thumb travels along — the ARIA APG's Switch pattern, built on the same native <input type="checkbox"> as PctCheckbox and carrying role="switch" over it. |
<pct-switch label="Wi-Fi" [(checked)]="wifi" />Each example is the component itself, not a screenshot. Open the code under any of them.
A switch takes effect at once — there is no Save behind it — so a disabled one says "this setting is not yours here" rather than "fill this in later".
Read from the source at build time — the types, the defaults and the lines of documentation are the library's own.
unreleased marks 1 of them: on main, and not in @pacit/components@0.2.0 — the next release names the version.
Switch. A setting that takes effect the moment it is moved, drawn as a track the thumb travels along — the ARIA APG's Switch pattern, built on the same native <input type="checkbox"> as PctCheckbox and carrying role="switch" over it.
| Name | Type | What it does |
|---|---|---|
checkedtwo-waydefault false | boolean | On/off — the only required field of the FormCheckboxControl contract. |
disableddefault false | boolean | Blocks the control and greys it — the native disabled, so it leaves the tab order as well. With [formField] the directive writes it, as it writes every input of the FormUiControl contract. |
readonlydefault false | boolean | The switch is a native checkbox, which has no readonly, so the click is swallowed here instead; the control stays focusable, unlike under disabled, and aria-readonly says so. |
invaliddefault false | boolean | The form's verdict; shown only once touched, so an empty form does not open red. |
toucheddefault false | boolean | Whether the user has left the field once; with invalid it gates the error face. |
requireddefault false | boolean | Marks the label with the required sign; with [formField] it follows the schema's required(). |
errorsdefault [] | readonly ValidationError.WithOptionalFieldTree[] | The form's validation errors; the first one's message takes the hint's place once the field is touched. |
warningsunreleaseddefault undefined | readonly ValidationError.WithOptionalFieldTree[] | undefined | A verdict without a veto (0087): shown after the error and before the hint once the field is touched, in the warning tone, with aria-invalid untouched. Left unbound, the control reads what pctWarn() wrote for its own [formField]; bound — [] included — the list given stands. |
namedefault '' | string | The native name — what a form submission calls the value. |
labeldefault '' | string | The visible label, rendered by the control itself when it stands outside a pct-field; inside one, the field's label is the name. |
hintdefault '' | string | A line of help under the control, outside a pct-field; the first error message takes its place while the field is invalid and touched. |
ariaLabeldefault '' | string | The accessible name of a switch with no visible label — an INPUT rather than an aria-label written on the tag, for the same reason as on the checkbox: the role sits on the <input> inside, the host has no role at all, and an ARIA name on a roleless element is ignored (req-a11y-built-in). |
ariaLabelledbydefault '' | string | As ariaLabel, for a name that already stands somewhere on the page. |
| Name | Payload | When it fires |
|---|---|---|
touch | void | Emitted on blur — lets the form mark the field as touched. |
The hooks a stylesheet or a test can hold. Stable by contract — the parts inventory gate fails the build when one disappears.
| Attribute | Bound to | Note |
|---|---|---|
class | pct-switch | |
data-pct-checked | checked() ? "" : null | |
data-pct-invalid | showInvalid() ? "" : null | |
data-pct-warning | warningLit() ? "" : null | |
data-pct-disabled | disabled() ? "" : null | |
data-pct-in-field | inField ? "" : null |
Two ways in, both stable. No class names to depend on.
In a test, PctSwitchHarness from @pacit/components/testing reads a part by the name below and a state by its attribute — the harness gate holds the names to the package.
| Part | Selector | What it is |
|---|---|---|
control | [data-pct-part="control"] | the native input |
error | [data-pct-part="error"] | the message when invalid |
hint | [data-pct-part="hint"] | the hint under the label |
label | [data-pct-part="label"] | the label beside the switch |
thumb | [data-pct-part="thumb"] | the knob — painted over the control, and it lets the pointer through to it |
track | [data-pct-part="track"] | the pill the thumb travels |
warning | [data-pct-part="warning"] | the message when the value is allowed and suspect — after the error, before the hint (0087) |
Every value the component reads, with the light and the dark default. Override a token at any scope and it cascades like any custom property.
| Token | Styles |
|---|---|
--pct-switch-fg-disabled{pct.text-disabled}#64748b | Text colour of the switch while disabled. |
--pct-switch-fg-invalid{pct.danger}#b91c1c · #f87171 | Text colour of the switch when invalid. |
--pct-switch-fg-warning{pct.warning}#b45309 · #fbbf24 | Text colour of the warning line (0087); the track keeps its colour, a warning being no fault of the control. |
--pct-switch-gap{pct.space.3}8px | Gap between the switch and its label. |
--pct-switch-height24px | Height of the switch. |
--pct-switch-hint-fg{pct.text-muted}#334155 · #e2e8f0 | Text colour of the hint. |
--pct-switch-label-fg{pct.text}#0f172a · #ffffff | Text colour of the label. |
--pct-switch-padding-x3px | Horizontal padding of the switch. |
--pct-switch-radius999px | Corner radius of the switch. |
--pct-switch-target-min{pct.target.min}24px | Touch-target floor of the switch. |
--pct-switch-thumb-bg{pct.border-strong}#64748b | Background of the thumb. |
--pct-switch-thumb-bg-checked{pct.on-primary}#ffffff · #0f172a | Background of the thumb when checked. |
--pct-switch-thumb-bg-disabled{pct.text-disabled}#64748b | Background of the thumb while disabled. |
--pct-switch-thumb-size16px | Size of the thumb. |
--pct-switch-track-bg{pct.surface}#ffffff · #0f172a | Background of the track. |
--pct-switch-track-bg-checked{pct.primary}#2563eb · #60a5fa | Background of the track when checked. |
--pct-switch-track-bg-disabled{pct.surface-disabled}#f1f5f9 · #1e293b | Background of the track while disabled. |
--pct-switch-track-border{pct.border-strong}#64748b | Edge colour of the track. |
--pct-switch-track-border-checked{pct.primary}#2563eb · #60a5fa | Edge colour of the track when checked. |
--pct-switch-track-border-disabled{pct.border}#e2e8f0 · #334155 | Edge colour of the track while disabled. |
--pct-switch-track-border-hover{pct.text-muted}#334155 · #e2e8f0 | Edge colour of the track on hover. |
--pct-switch-track-border-invalid{pct.danger}#b91c1c · #f87171 | Edge colour of the track when invalid. |
--pct-switch-width40px | Width of the switch. |
A brand override in a few declarations. Scope it to a theme attribute, a page or one container — the component never notices.
[data-theme='brand'] {
--pct-switch-track-bg-checked: #0f766e;
--pct-switch-thumb-bg: #ffffff;
} Pattern: Switch — the role="switch" variant built on a native checkbox, which the APG's own HTML example is
| key | effect | test |
|---|---|---|
Space | toggle | native <input type="checkbox"> |
No handling of its own (req-api-platform) — and that is the point of the native road: the APG lists Space for the pattern and the platform already implements it. readonly blocks the change without losing focusability.
Every line is a test in three engines or a gate on every commit. The gaps are listed the same way, each with the requirement that will close it.
libs/components/switch/src/switch.ts — the pattern is named and linked in the class commentapps/sandbox-e2e/src/switch.spec.ts — Space measured in three engines, because "the platform does it" is a claim about three of themapps/sandbox-e2e/src/a11y.spec.ts — the /switch view and /all, in both directionsapps/sandbox-e2e/src/visual.spec.ts — switch-states and switch-states-rtlforced-colors: activeapps/sandbox-e2e/src/forced-colors.spec.ts — the state is the thumb's position, so the two states are deliberately the same colour after the swap and the case asserts exactly that; plus the disabled row, which says GrayText like the restprefers-reduced-motionapps/sandbox-e2e/src/preferences.spec.ts — the thumb's travel is a transition, so it joins the cross-control caseapps/sandbox-e2e/src/switch.spec.ts — the track is 40×24 and carries the floor itself (max(…, --pct-switch-target-min)), so what a finger gets is what an eye sees less the 1 px border, at every size; the checkbox needs the other technique because its box is 18 px (lesson-14). The knob painted over the control is no hole in that area: it takes no pointer events, and the same spec presses its centre and watches the state turn — before that line the one part that looked most like the thing to press was the one part that did nothing (lesson-158)apps/sandbox-e2e/src/switch.spec.ts — the thumb's travel measured as geometry in both directions, plus the switch-states-rtl baselineapps/sandbox-e2e/src/hydration.spec.ts — the /switch view is in SBX_ROUTESlibs/components/switch/src/switch.spec.ts — signal forms, [formControl] and [(ngModel)]; a form's reset — reset() keeping the value, reset(value) showing it, the form pristine: libs/components/switch/src/switch.spec.ts › signal formstools/check-aria.mjs (point 7, target check-aria) — the error part carries role="alert", over every template of the librarylibs/components/parts.snapshot.md, tools/check-parts.mjs (target check-parts)contrast.policy.jsonlibs/tokens/src/contrast.policy.json — 13 pairs, track/thumb added to the name dictionaryPCT_TEXTStools/check-texts.mjs — no strings of its ownlibs/components/size.snapshot.md, tools/check-bundle.mjs (target check-bundle)docs/acr/at/ — three readers, whole sandbox. All three give the role and the hint; they disagree about the words for the state. NVDA Wi-Fi, switch, on, Turns off when you leave the house; VoiceOver Backups Runs every night at 03:00 required invalid data off switch; Orca Backups · switch not pressed. · Runs every night at 03:00. — pressed/not pressed, the toggle-button vocabulary, where the other two say on and off. And the READ-ONLY switch is switch, on to NVDA — aria-readonly is on the control and the reader does not say it, as with the checkbox; VoiceOver left this view after one stop and did not reach it/components/switch on the published site — prerendered, the demo's own source is the code tabRead at build time from the tracked snapshots, not typed here.
ControlValueAccessorFormCheckboxControl requires checked, not valueswitch true and false and nothing else, so there is no indeterminate here — and no audit would have said so, which is why the absence of the input is the gate rather than a rule somebody has to remember.aria-hidden (the role already says the state) and would need a string per language for something the position already tells the eye. It is a limitation rather than a decision: a consumer who wants it today has to draw it themselves.