On this page
Choices

Radio released

A group of mutually exclusive options, where the group is the form control and the options are its values.

  • Import@pacit/components/radio
  • Selectorspct-radio-group, pct-radio

Implements the W3C ARIA APG Radio Group pattern ↗

Chosen: pro

radio.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 { PctRadioGroup, PctRadio } from '@pacit/components/radio';
ExportedWhat it is
PctRadioOptioninterfaceWhat the group needs to know about an option, and nothing more: the value it stands for and a name to call it by in a message. Declared HERE, beside the group, so that the option can provide it while importing the group — the other direction (contentChildren(PctRadio)) would close the import cycle.
PCT_RADIO_OPTIONtokenThe channel through which pct-radio announces itself to the group.
PctRadioGroupcomponentpct-radio-groupA single-choice group. The group is the form control (FormValueControl), not the individual options — from the form's point of view a radiogroup edits one value (req-api-signal-forms).
PctRadiocomponentpct-radioA single option inside pct-radio-group. Not a form control of its own — the group holds the state (req-api-signal-forms). It stands on a native <input type="radio"> with a shared name, so arrow navigation and Tab behaviour come from the browser rather than from a roving-tabindex implementation of ours.
<pct-radio-group ariaLabel="Plan" [(value)]="plan">
  <pct-radio value="free">Free</pct-radio>
  <pct-radio value="pro">Pro</pct-radio>
</pct-radio-group>
Examples

Every state, running

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

In a row, with a hint

orientation="horizontal" lays the options on one line for a short list; label and hint name the whole group, which is what a screen reader announces first.

Billing
Yearly saves two months

Billed yearly.

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.

PctRadioGroupcomponent · pct-radio-group

A single-choice group. The group is the form control (FormValueControl), not the individual options — from the form's point of view a radiogroup edits one value (req-api-signal-forms).

Inputs

NameTypeWhat it does
valuetwo-waydefault nullT | nullThe selected value — a required field of the FormValueControl contract. null means "no option is selected": the state the group is born in.
disableddefault falsebooleanDisables every option at once — each native radio takes it, so the group leaves the tab order. With [formField] the directive writes it, as it writes every input of the FormUiControl contract.
readonlydefault falsebooleanHolds the choice while the options stay focusable; aria-readonly goes on the group, because the radio role has none.
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 shared name attribute of the native radios; generated by default.
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.
orientationdefault 'vertical''vertical' | 'horizontal'Which axis the options run along; written to aria-orientation and to the state attribute the stylesheet lays the group out by.
compareWithdefault pctSameValuePctCompareWith<T>Value equality — as in pct-select; entities are compared by key.
emptyValuedeprecatedsince 0.3.0 — reset the form with the value it should hold instead: form().reset({ plan: null }). Removed no earlier than 0.5.0.default nullT | nullThe "no choice" value. It was what a form's reset wrote into the model, and a reset writes nothing there any more, so nothing reads it (0092).

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-radio-group
roleradiogroup
aria-labelledbylabelledBy()
aria-describedbydescribedBy()
aria-invalidshowInvalid() ? "true" : null
aria-requiredrequired() ? "true" : null
aria-readonlyreadonly() ? "true" : nullaria-readonly belongs to the GROUP, not to an option: the radio role does not support it (radiogroup does), and an attribute disallowed for a role is a critical violation, not a cosmetic one (lesson-33).
aria-orientationorientation()
data-pct-orientationorientation()
data-pct-invalidshowInvalid() ? "" : null
data-pct-warningwarningLit() ? "" : null
data-pct-disableddisabled() ? "" : null
data-pct-in-fieldinField ? "" : null

PctRadiocomponent · pct-radio

A single option inside pct-radio-group. Not a form control of its own — the group holds the state (req-api-signal-forms). It stands on a native <input type="radio"> with a shared name, so arrow navigation and Tab behaviour come from the browser rather than from a roving-tabindex implementation of ours.

Inputs

NameTypeWhat it does
valuerequiredTThe value this option stands for.
disableddefault falsebooleanDisables a single option; the group can disable them all.
ariaLabeldefault ''stringThe accessible name of an option whose projected content is not text — an icon, a colour swatch. An INPUT rather than an aria-label written on the tag, because the tag cannot carry one: role="radio" 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 ''stringAs ariaLabel, for a name that already stands elsewhere on the page. It wins over both.

Outputs

None of its own. The native events — (click), (focus), the form's submit — are the platform's, and two-way models above carry the rest.

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-radio
data-pct-checkedchecked() ? "" : null
data-pct-disabledisDisabled() ? "" : null
data-pct-invalidgroup.showInvalid() ? "" : null
Styling

Parts to select, tokens to override

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

Parts

In a test, PctRadioGroupHarness, PctRadioHarness from @pacit/components/testing read a part by the name below and a state by its attribute — the harness gate holds the names to the package.

PartSelectorWhat it is
circle[data-pct-part="circle"]the ring drawn over it
control[data-pct-part="control"]the native input of an option
dot[data-pct-part="dot"]the mark inside the chosen ring
label[data-pct-part="label"]the text of an option
group-error[data-pct-part="group-error"]the message when the group is invalid
group-hint[data-pct-part="group-hint"]the hint under the group
group-label[data-pct-part="group-label"]the label of the whole group
group-options[data-pct-part="group-options"]the list of options
group-warning[data-pct-part="group-warning"]the message when the choice 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-radio-bg{pct.surface}#ffffff · #0f172aBackground of the radio.
--pct-radio-bg-disabled{pct.surface-disabled}#f1f5f9 · #1e293bBackground of the radio while disabled.
--pct-radio-border{pct.border-strong}#64748bEdge colour of the radio.
--pct-radio-border-checked{pct.primary}#2563eb · #60a5faEdge colour of the radio when checked.
--pct-radio-border-disabled{pct.border}#e2e8f0 · #334155Edge colour of the radio while disabled.
--pct-radio-border-hover{pct.text-muted}#334155 · #e2e8f0Edge colour of the radio on hover.
--pct-radio-border-invalid{pct.danger}#b91c1c · #f87171Edge colour of the radio when invalid.
--pct-radio-dot-bg{pct.primary}#2563eb · #60a5faBackground of the dot.
--pct-radio-dot-size8pxSize of the dot.
--pct-radio-fg-disabled{pct.text-disabled}#64748bColour of the dot while disabled.
--pct-radio-fg-invalid{pct.danger}#b91c1c · #f87171Colour of the dot when invalid.
--pct-radio-fg-warning{pct.warning}#b45309 · #fbbf24Text colour of the warning line (0087); the ring keeps its colour, a warning being no fault of the control.
--pct-radio-gap{pct.space.3}8pxGap between the radio and its label.
--pct-radio-group-label-fg{pct.text}#0f172a · #ffffffText colour of the group label.
--pct-radio-hint-fg{pct.text-muted}#334155 · #e2e8f0Text colour of the hint.
--pct-radio-label-fg{pct.text}#0f172a · #ffffffText colour of the label.
--pct-radio-option-gap{pct.space.3}8pxGap between the options of a group.
--pct-radio-size18pxSize of the radio.
--pct-radio-target-min{pct.target.min}24pxTouch-target floor of the radio.

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-radio-border-checked: #0f766e;
  --pct-radio-dot-bg: #0f766e;
}

Chosen: pro

Accessibility

Measured, not assumed

Pattern: Radio Group — role="radiogroup", aria-labelledby, aria-orientation

Keyboard

keyeffectsource
↑ ↓ ← →move between options, wrapping aroundnative <input type="radio"> sharing a name
Tabone stop in the Tab order for the whole groupnative

This is the model implementation of req-api-platform: no roving tabindex of our own. Tested in apps/sandbox-e2e/src/radio.spec.ts.

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
MeasuredKeyboard map testedapps/sandbox-e2e/src/radio.spec.ts
Measuredaxe auditapps/sandbox-e2e/src/a11y.spec.ts — including the /states view with a read-only group, which found aria-readonly on the radio role (lesson-33)
MeasuredVisual screenshotapps/sandbox-e2e/src/visual.spec.ts
Measuredforced-colors: activeapps/sandbox-e2e/src/forced-colors.spec.ts — this is where a regression surfaced: the dot was a plain <div> with a background, so a selected option looked identical to an empty one (lesson-40). The second one no browser here shows: the disabled circle kept a theme surface, and tools/check-styles.mjs (point 7) is what says so (lesson-70)
Measuredprefers-reduced-motionapps/sandbox-e2e/src/preferences.spec.ts
MeasuredTouch target ≥ 24×24 pxapps/sandbox-e2e/src/radio.spec.ts
n/aSize axisnot applicable — the bare variant
GapDensity axisnone — gap
In partRTLpartly — tools/check-styles.mjs (point 5, req-token-logical) over the stylesheet — one justified pct-exception, the symmetric left: 50% of the hit zone — and the radio-in-wrapper-rtl baseline in apps/sandbox-e2e/src/visual.spec.ts. Not yet: with orientation="horizontal" the Left/Right arrows have to swap, and no test presses them under dir="rtl"
MeasuredSSR + hydrationapps/sandbox-e2e/src/hydration.spec.ts
MeasuredFormslibs/components/radio/src/radio.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/radio/src/radio.spec.ts › signal forms
MeasuredParts in the inventorylibs/components/parts.snapshot.md, tools/check-parts.mjs (target check-parts). Note: label collided with the options' labels — hence the group- prefix (lesson-15), which every part of the group carries, group-options included (point 6 of the gate)
MeasuredTokens + contrast.policy.jsonlibs/tokens/src/contrast.policy.json
MeasuredStrings through PCT_TEXTStools/check-texts.mjs — no strings of its own; the dev-mode report of a duplicated value is addressed to the developer, so it is permanently in English and outside PCT_TEXTS (req-api-texts)
MeasuredSize budgetlibs/components/size.snapshot.md — the ./radio row
MeasuredScreen-reader logRead 2026-09-28 from docs/acr/at/ — three readers, whole sandbox. Orca and VoiceOver give the whole shape: Plan · panel · Free · not selected radio button. and Free radio button, 1 of 3 Plan You can change it at any time required radio group — group, option, state, and for VoiceOver the position, the hint and required. NVDA announces the group and stops: Horizontal layout, grouping, with no option and no state, at every radio stop of this walk. Whether that is NVDA in browse mode or the group itself is the next question, and it is a specific one. VoiceOver left this view after one stop, so its reading covers the first option
Measureddocs page/components/radio 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.

91.78%mutation score134 of 146 mutants killed
7 × 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
18elements in the preview10 listeners · 1 render to settle · 4366 µs in jsdom, held exactly but for the clock

Why it is built this way

The library's first composite component. The form control is the group, not the options (req-api-container).

Decisions behind it

  • 0005Signal forms without ControlValueAccessor
  • 0010A generic value and NoInfer
  • 0003Wrapper and control
  • lesson-66When the only witness of a defect is somebody else's diagnostic, the cheap fix silences it
  • lesson-33The states view found a disallowed ARIA attribute
  • 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-15A part-name collision only surfaced in e2e
  • lesson-16Options in a group are projected content, not viewChildren

Known limitations

  • $event from (valueChange) is not checked in the template. The group has no options input (they are projected content), so the only source of T is value itself — and then NoInfer has nothing to protect. That is a limitation of Angular, not of the API (0010).
  • The container does not see the options through viewChildren, because they are projected. focus() therefore queries the host's DOM (lesson-16) — the native controls are what the browser checks. What the options MEAN travels the other road: a token (PCT_RADIO_OPTION) the option provides, which leaves the import pointing one way only where contentChildren(PctRadio) would have closed the cycle. The DOM could not have carried it — [attr.value] is absent for a non-primitive T.
  • The native radio's value attribute disappears for non-primitive T — [object Object] in the DOM would look like a value while identifying nothing.