On this page
Choices

Checkbox released

A box the reader ticks, with the third state a parent of many options needs.

  • Import@pacit/components/checkbox
  • Selectorpct-checkbox

Implements the W3C ARIA APG Checkbox (tri-state) pattern ↗

Release notes: yes

checkbox.demo.ts — the same file runs here and is highlighted under Code

Start here

Usage

One import per component, and the shortest possible use — the package itself is installed once, on the start page.

Import this component
import { PctCheckbox } from '@pacit/components/checkbox';
ExportedWhat it is
PctCheckboxcomponentpct-checkboxCheckbox. A native signal-forms control — it implements FormCheckboxControl (req-api-signal-forms). That contract requires checked, and the component may not define a value property (reserved for FormValueControl).
<pct-checkbox label="Send me release notes" [(checked)]="notes" />
Examples

Every state, running

Each example is the component itself, not a screenshot. Open the code under any of them.

The states a form drives

required, invalid and touched are the form's to set — the FormField directive keeps them in sync — and here they are set by hand to show each face: the error appears once the box is both invalid and touched, never before.

Required to continue
Not on this plan
API

What it reads, and what it writes back

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.

Checkbox. A native signal-forms control — it implements FormCheckboxControl (req-api-signal-forms). That contract requires checked, and the component may not define a value property (reserved for FormValueControl).

Inputs

NameTypeWhat it does
checkedtwo-waydefault falsebooleanChecked state — the only required field of the FormCheckboxControl contract.
disableddefault falsebooleanBlocks 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 falsebooleanA native checkbox has no readonly, so the click is swallowed here instead; the control stays focusable, unlike under disabled, and aria-readonly says so.
invaliddefault falsebooleanThe form's verdict; shown only once touched, so an empty form does not open red.
toucheddefault falsebooleanWhether the user has left the field once; with invalid it gates the error face.
requireddefault falsebooleanMarks 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 undefinedreadonly ValidationError.WithOptionalFieldTree[] | undefinedA 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 ''stringThe native name — what a form submission calls the value.
labeldefault ''stringThe visible label, rendered by the control itself when it stands outside a pct-field; inside one, the field's label is the name.
hintdefault ''stringA 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 ''stringThe accessible name of a checkbox with no visible label — an INPUT rather than an aria-label written on the tag, because the tag cannot carry one: the role sits on the <input> inside, the host has no role at all, and an ARIA name on a roleless element is ignored (aria-label is prohibited for the generic role) (req-a11y-built-in).
ariaLabelledbydefault ''stringAs ariaLabel, for a name that already stands somewhere on the page — the header of the column a checkbox in a row belongs to. It wins over ariaLabel and over label.
indeterminatedefault falsebooleanIndeterminate state (a partial choice in a group, say). It is the native indeterminate PROPERTY and nothing else: the accessible tree reports it as mixed from the property alone, and an aria-checked written beside it would be ignored in both directions — measured in three engines and in Chromium's own tree, so none is written (0039, lesson-112).

Outputs

NamePayloadWhen it fires
touchvoidEmitted on blur — lets the form mark the field as touched.

What it writes on the element

The hooks a stylesheet or a test can hold. Stable by contract — the parts inventory gate fails the build when one disappears.

AttributeBound toNote
classpct-checkbox
data-pct-checkedchecked() ? "" : null
data-pct-indeterminateindeterminate() ? "" : null
data-pct-invalidshowInvalid() ? "" : null
data-pct-warningwarningLit() ? "" : null
data-pct-disableddisabled() ? "" : null
data-pct-in-fieldinField ? "" : null
Styling

Parts to select, tokens to override

Two ways in, both stable. No class names to depend on.

Parts

In a test, PctCheckboxHarness 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.

PartSelectorWhat it is
box[data-pct-part="box"]the square drawn over it
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 box
mark[data-pct-part="mark"]the check inside the box
warning[data-pct-part="warning"]the message when the value is allowed and suspect — after the error, before the hint (0087)

Tokens

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.

TokenStyles
--pct-checkbox-bg{pct.surface}#ffffff · #0f172aBackground of the box.
--pct-checkbox-bg-checked{pct.primary}#2563eb · #60a5faBackground of the box when checked.
--pct-checkbox-bg-disabled{pct.surface-disabled}#f1f5f9 · #1e293bBackground of the box while disabled.
--pct-checkbox-border{pct.border-strong}#64748bEdge colour of the box.
--pct-checkbox-border-checked{pct.primary}#2563eb · #60a5faEdge colour of the box when checked.
--pct-checkbox-border-disabled{pct.border}#e2e8f0 · #334155Edge colour of the box while disabled.
--pct-checkbox-border-hover{pct.text-muted}#334155 · #e2e8f0Edge colour of the box on hover.
--pct-checkbox-border-invalid{pct.danger}#b91c1c · #f87171Edge colour of the box when invalid.
--pct-checkbox-fg-checked{pct.on-primary}#ffffff · #0f172aColour of the check mark.
--pct-checkbox-fg-disabled{pct.text-disabled}#64748bColour of the check mark while disabled.
--pct-checkbox-fg-invalid{pct.danger}#b91c1c · #f87171Colour of the check mark when invalid.
--pct-checkbox-fg-warning{pct.warning}#b45309 · #fbbf24Text colour of the warning line (0087); the mark keeps its colour, a warning being no fault of the control.
--pct-checkbox-gap{pct.space.3}8pxGap between the box and its label.
--pct-checkbox-hint-fg{pct.text-muted}#334155 · #e2e8f0Text colour of the hint.
--pct-checkbox-label-fg{pct.text}#0f172a · #ffffffText colour of the label.
--pct-checkbox-radius4pxCorner radius of the box.
--pct-checkbox-size18pxSize of the box.
--pct-checkbox-target-min{pct.target.min}24pxTouch-target floor of the box.

Theming

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-checkbox-bg-checked: #0f766e;
  --pct-checkbox-border-checked: #0f766e;
  --pct-checkbox-fg-checked: #ffffff;
}

Release notes: yes

Accessibility

Measured, not assumed

Pattern: Checkbox (tri-state) — the third state is the native indeterminate property, which the accessible tree reports as mixed; no aria-checked is written (0039)

Keyboard

keyeffecttest
Spacetogglenative <input type="checkbox">

No handling of its own (req-api-platform). readonly blocks the change without losing focusability.

What is measured, and what is not yet

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.

GapARIA APG pattern in the JSDocnone — gap
n/aKeyboard mapnot applicable — native
MeasuredNo aria-checked of its ownlibs/components/checkbox/src/checkbox.spec.ts › never writes aria-checked — the absence in all three states; apps/sandbox-e2e/src/checkbox.spec.ts — the accessible tree read in three engines, [checked] from the checkedness and [checked=mixed] from the indeterminate property alone (0039, lesson-112)
Measuredaxe auditapps/sandbox-e2e/src/a11y.spec.ts — including a separate test of the indeterminate state
MeasuredVisual screenshotapps/sandbox-e2e/src/visual.spec.ts
Measuredforced-colors: activeapps/sandbox-e2e/src/forced-colors.spec.ts — the tick toggles visibility, so the state is carried by the presence of a shape, not by colour (lesson-40); tools/check-styles.mjs (point 7) found the disabled box keeping a theme surface instead of Field (lesson-70)
Measuredprefers-reduced-motionapps/sandbox-e2e/src/preferences.spec.ts
MeasuredTouch target ≥ 24×24 pxapps/sandbox-e2e/src/checkbox.spec.ts — the box is 18 px, the hit zone is enlarged and centred; met outright, not through the spacing exception (lesson-14)
n/aSize axisnot applicable — the bare variant does not align heights (0004)
GapDensity axisnone — gap. Elevated risk: density will drop below the touch threshold sooner than size sm
MeasuredRTLtools/check-styles.mjs (point 5, req-token-logical) over the stylesheet — one justified pct-exception, the symmetric left: 50% of the hit zone, where inset-inline-start would be a defect and not an improvement — plus the checkbox-in-wrapper-rtl baseline in apps/sandbox-e2e/src/visual.spec.ts
MeasuredSSR + hydrationapps/sandbox-e2e/src/hydration.spec.ts
MeasuredFormslibs/components/checkbox/src/checkbox.spec.ts, apps/sandbox-e2e/src/forms.spec.ts; a form's reset — reset() keeping the value, reset(value) showing it, the form pristine: libs/components/checkbox/src/checkbox.spec.ts › signal forms
MeasuredThe mark through PCT_ICONStools/check-icons.mjs (target check-icons), libs/components/checkbox/src/checkbox.spec.ts — check and indeterminate are two names, and a set carrying one of them leaves the other to the component
MeasuredParts in the inventorylibs/components/parts.snapshot.md, tools/check-parts.mjs (target check-parts). Note: the control part collided with the native input once wrapped (lesson-24)
MeasuredTokens + contrast.policy.jsonlibs/tokens/src/contrast.policy.json
MeasuredStrings through PCT_TEXTStools/check-texts.mjs — no strings of its own
MeasuredSize budgetlibs/components/size.snapshot.md — the ./checkbox row
MeasuredScreen-reader logRead 2026-10-12 from docs/acr/at/ — three readers, whole sandbox. mixed is announced by every reader that reaches it: NVDA The indeterminate state, read as mixed, check box, half checked; VoiceOver reached it in the pass of 2026-10-08 (dd0f02fa) — The indeterminate state, read as mixed mixed checkbox, and the read-only box Read-only — focusable, but not changeable checked checkbox — and left this view after one stop on 2026-09-28 and on 2026-10-12. Orca never reaches it — its walk ends after one stop, focus leaving main for the scaffold — and its one reading is the required box, Consents · check box not checked required. · invalid entry. · Required to open an account., which VoiceOver read whole on 2026-10-12, Consents Required to open an account required invalid data unchecked checkbox, where on 2026-10-08 it said only the message. The READ-ONLY box is check box, checked to NVDA: the words read-only reach it in the label and the state does not, although aria-readonly is on the control — an attribute the reader does not say
Measureddocs page/components/checkbox on the published site — prerendered, the demo's own source is the code tab
Evidence

What the repository holds for this component

Read at build time from the tracked snapshots, not typed here.

96.83%mutation score61 of 63 mutants killed
5 × 3e2e cases × enginesplus the shared audits: axe, forced colors, preferences, size, hydration
13colour pairs measuredlight and dark, at every token build
2visual baselinesChromium, the sandbox's own pictures
17elements in the preview7 listeners · 1 render to settle · 5099 µs in jsdom, held exactly but for the clock

Decisions behind it

  • 0005Signal forms without ControlValueAccessor
  • 0003Wrapper and control
  • 0028An icon set is a component, and an icon's box is the contract
  • lesson-12FormCheckboxControl requires checked, not value
  • lesson-112An ARIA attribute over a native state is written by us and read by nobody
  • lesson-40A state carried by background alone disappears in high contrast
  • lesson-70A media query adds no specificity, and the browser it is written for hides that
  • lesson-14Formal conformance does not mean good quality
  • lesson-24The part-name collision repeated itself with the wrapper

Known limitations

  • No switch variant — that is a separate component despite the same FormCheckboxControl contract, because the semantics differ ("turn on now" vs "tick to submit").