Radio Group
Single-select from an options list; the value is the chosen string. Vertical or horizontal orientation, per-option disabled, Space/Enter selection, pure-CSS dot. The options attribute accepts a JSON string. Emits a composed change event.
Preview
<esa-radio-group
label="Priority"
options='[{"label":"Low","value":"low"}, …]'
value="medium"
></esa-radio-group> import { Component, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import '@esa/ecology/esa-radio-group';
@Component({
selector: 'app-radio-group-demo',
standalone: true,
schemas: [CUSTOM_ELEMENTS_SCHEMA],
template: `
<esa-radio-group
label="Priority"
options='[{"label":"Low","value":"low"}, …]'
value="medium"
></esa-radio-group>
`,
})
export class RadioGroupDemo {} Orientation
vertical is the default; switch to horizontal to lay options out in a row.
<esa-radio-group label="Priority" orientation="horizontal" options="…" value="low"></esa-radio-group> import { Component, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import '@esa/ecology/esa-radio-group';
@Component({
selector: 'app-radio-group-demo',
standalone: true,
schemas: [CUSTOM_ELEMENTS_SCHEMA],
template: `
<esa-radio-group label="Priority" orientation="horizontal" options="…" value="low"></esa-radio-group>
`,
})
export class RadioGroupDemo {} Sizes
<esa-radio-group label="Extra small" size="xs" orientation="horizontal" options="…" value="low"></esa-radio-group>
<esa-radio-group label="Small" size="sm" orientation="horizontal" options="…" value="low"></esa-radio-group>
<esa-radio-group label="Medium" size="md" orientation="horizontal" options="…" value="medium"></esa-radio-group>
<esa-radio-group label="Large" size="lg" orientation="horizontal" options="…" value="high"></esa-radio-group> import { Component, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import '@esa/ecology/esa-radio-group';
@Component({
selector: 'app-radio-group-demo',
standalone: true,
schemas: [CUSTOM_ELEMENTS_SCHEMA],
template: `
<esa-radio-group
label="Extra small"
size="xs"
orientation="horizontal"
options="…"
value="low"
></esa-radio-group>
<esa-radio-group
label="Small"
size="sm"
orientation="horizontal"
options="…"
value="low"
></esa-radio-group>
<esa-radio-group
label="Medium"
size="md"
orientation="horizontal"
options="…"
value="medium"
></esa-radio-group>
<esa-radio-group
label="Large"
size="lg"
orientation="horizontal"
options="…"
value="high"
></esa-radio-group>
`,
})
export class RadioGroupDemo {} Form participation
The selected value is submitted under name. Submitting logs the priority entry.
<form>
<esa-radio-group label="Priority" name="priority" options="…" value="low"></esa-radio-group>
<button type="submit">Submit</button>
</form> import { Component, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import '@esa/ecology/esa-radio-group';
@Component({
selector: 'app-radio-group-demo',
standalone: true,
schemas: [CUSTOM_ELEMENTS_SCHEMA],
template: `
<form>
<esa-radio-group label="Priority" name="priority" options="…" value="low"></esa-radio-group>
<button type="submit">Submit</button>
</form>
`,
})
export class RadioGroupDemo {} API
| Prop | Type | Default | Description |
|---|---|---|---|
options | EsaOption[] | [] | Array of {'{ label, value, disabled? }'}, or a JSON string of the same when set as an attribute. |
label | string | '' | Group label rendered above the options. |
size | 'xs' | 'sm' | 'md' | 'lg' | 'md' | Circle and label size. |
orientation | 'vertical' | 'horizontal' | 'vertical' | Stacking direction of the options. |
name | string | undefined | — | Form field name — the key this control submits under. |
value | string | null | null | The single selected option value. |
required | boolean | false | Whether an option must be chosen. Marked on the GROUP, never on each radio — putting `aria-required` on every option announces "required" per option, which reads as "you must select all of these". The group is the thing that is required. |
help-text | string | '' | Property: `helpText`. |
error-text | string | '' | Validation message below the group; reddens the legend and renders the error line. Property: `errorText`. |
live-error | boolean | false | Announce the error the moment it appears rather than only when the group is entered. Off by default — see the long note on `esa-text-field.liveError`. Property: `liveError`. |
Events
| Event | Type | Default | Description |
|---|---|---|---|
change | CustomEvent<{ value: string }> | — | Fired when a new option is selected. Composed and bubbling; detail.value is the chosen string. |
Binding these events from Angular
Every one of these is a CustomEvent, so the payload is on
$event.detail — $event.target.value reads empty, with no
error. The names are lowercase and Angular passes them to
addEventListener verbatim, so camel-casing one means it never fires.
(change)="onChange($event.detail)"
Accessibility
- The options container has
role="radiogroup", named byaria-labelfrom thelabelattribute — so leavinglabelunset leaves the group unnamed. Always set it, including inside anesa-form-field. - Each option has
role="radio"witharia-checkedand selects on Space or Enter. - Known gaps. Arrow keys do not move selection, and every option is a tab stop rather than one roving tab stop — the WAI-ARIA radiogroup pattern expects the reverse. The option text also sits in a sibling
<span>: the wrapping<label>cannot name a<span role="radio">, so options currently announce without their text. - Disabled options carry
aria-disabledand a-1tabindex. - The whole option row is the click target, not just the circle.
- The
changeevent is composed and bubbles, so it can be caught on the host or an ancestor.
Theming surface
Every public token this component reads, extracted from its source at build time,
split by blast radius: the hooks that belong to this component,
then the shared and system tokens it consumes alongside everything else. The
Resolves to column walks each token's real lineage —
component → semantic → primitive → raw value — so you
can see exactly where re-pointing it lands. A spoke re-skins by overriding these in
its theme-<slug>.css; component internals are never edited. No
hook for what you need? File it with /request-lego.
Type roles it names
This component does not set a size, weight, face or tracking of its own — it names a composite, and the composite carries all five. A composite is a class rather than a token, so none of it appears in the tables below; re-theme text by re-pointing the token family, not by editing the component.
| Role | Re-point | Reach |
|---|---|---|
.typography-body-2xs |
| +6 more componentsesa-checkbox, esa-checkbox-group, esa-combobox, esa-input-tag, esa-progress-bar, esa-textarea |
.typography-body-lg |
| +7 more componentsesa-breadcrumbs, esa-checkbox, esa-checkbox-group, esa-combobox, esa-input-tag, esa-select, esa-textarea |
.typography-body-md |
| +28 more componentsesa-app-shell, esa-breadcrumbs, esa-card, esa-checkbox, esa-checkbox-group, esa-collapsible, esa-combobox, esa-command-palette, esa-confirm-dialog, esa-container, esa-danger-zone, esa-dialog, esa-dropdown-menu, esa-empty-state, esa-entity-search, esa-form-field, esa-header-nav, esa-input-tag, esa-nav-dropdown, esa-page-header, esa-popover, esa-progress-bar, esa-select, esa-side-dialog, esa-snackbar-item, esa-switch-toggle, esa-tab-layout, esa-textarea |
.typography-body-sm |
| +20 more componentsesa-alert-box, esa-breadcrumbs, esa-button-toggle, esa-card, esa-checkbox-group, esa-combobox, esa-command-palette, esa-date-picker, esa-empty-state, esa-file-upload, esa-form-field, esa-input-tag, esa-link-column, esa-loading-overlay, esa-progress-bar, esa-search-panel, esa-select, esa-stat, esa-text-field, esa-textarea |
.typography-body-xs |
| +14 more componentsesa-breadcrumbs, esa-checkbox, esa-checkbox-group, esa-combobox, esa-command-palette, esa-empty-state, esa-entity-search, esa-field-error, esa-header-nav, esa-input-tag, esa-progress-bar, esa-search-panel, esa-select, esa-textarea |
.typography-label-2xs |
| +11 more componentsesa-button-toggle, esa-checkbox-group, esa-color-picker, esa-combobox, esa-date-picker, esa-form-field, esa-input-tag, esa-progress-bar, esa-range-slider, esa-text-field, esa-textarea |
.typography-label-lg |
| +12 more componentsesa-breadcrumbs, esa-button-toggle, esa-checkbox-group, esa-color-picker, esa-combobox, esa-date-picker, esa-form-field, esa-input-tag, esa-range-slider, esa-select, esa-text-field, esa-textarea |
.typography-label-md |
| +33 more componentsesa-app-bar, esa-breadcrumbs, esa-button-group, esa-button-toggle, esa-card, esa-checkbox-group, esa-color-picker, esa-combobox, esa-command-palette, esa-confirm-dialog, esa-danger-zone, esa-date-picker, esa-dialog, esa-empty-state, esa-entity-search, esa-file-upload, esa-filter-container, esa-form-field, esa-header-nav, esa-input-tag, esa-link-column, esa-page-header, esa-pillbox, esa-popover, esa-progress-bar, esa-range-slider, esa-select, esa-side-dialog, esa-sidebar-nav, esa-stat, esa-text-field, esa-textarea, esa-tooltip |
.typography-label-xs |
| +14 more componentsesa-breadcrumbs, esa-button-toggle, esa-checkbox-group, esa-color-picker, esa-combobox, esa-date-picker, esa-entity-search, esa-form-field, esa-input-tag, esa-progress-bar, esa-range-slider, esa-select, esa-text-field, esa-textarea |
Wired to this component
None. This component has no hooks of its own — it themes entirely through the shared tokens below, so every override here also moves its siblings.
Shared tokens it reads
Owned by a family or by the semantic layer. Still overridable — but scope the
override (esa-foo { --form-border-color: … }) unless you mean to move
everything that reads it. Reach says what else that is.
| Token | Reach | Resolves to |
|---|---|---|
--focus-ring-color | component focus ring group · +34 more | #3e9b4f via --color-border-default-focus → --color-grass-10 |
--focus-ring-offset | component focus ring group · +31 more | 2px |
--focus-ring-width | component focus ring group · +35 more | 2px via --border-width-focus → --border-width-200 |
--form-border-color | component forms group · +11 more | one role per property, so a theme can move the ring's weight and its colour independently. Tier 3's --focus-ring-width now reads this instead of holding a bare 2px, which is the same shape --focus-ring-color has always had (a thin tier-3 alias over a tier-2 role) — width was simply never given the tier-2 half.
*
* This role was rejected once, and the rejection is worth recording because its PREMISE was wrong rather than its reasoning. The argument against an `emphasis` width was that every 2px border in the kit is micro-geometry — spinner rings, the kbd keycap lip, a chevron. The focus ring was not in that enumeration and does not fit it: 33 components render it, more than read any other tier-3 token, and it is not micro-geometry but an accessibility affordance whose weight is a legitimate brand axis (a spoke that needs a heavier ring for contrast has to be able to reach it). It is also not a `border` in any component — every consumer paints it with `outline` or `box-shadow` — which is precisely why a survey of BORDERS missed it. The name still says `border` because tier 2 already calls the ring's colour --color-border-default-focus via --color-border-default |
--form-border-color-focus | component forms group · +11 more | #3e9b4f via --color-border-default-focus → --color-grass-10 |
--form-border-width | component forms group · +10 more | 1px via --border-width-default → --border-width-100 |
--form-error-border-color | component forms group · +9 more | #ce2c31 via --color-background-utility-danger → --color-red-11 |
--form-error-color | component forms group · +9 more | #ce2c31 via --color-content-utility-danger → --color-red-11 |
--form-help-color | component forms group · +9 more | #646464 via --color-content-default-secondary → --color-gray-11 |
--form-help-gap | component forms group · +5 more | 0.25rem via --spacing-100 |
--color-background-brand | semantic every component | #46a758 via --color-grass-9 |
--color-background-disabled | semantic every component | #f0f0f0 via --color-gray-3 |
--color-background-field | semantic every component | rgba(0, 0, 0, 0) |
--color-border-disabled | semantic every component | #d9d9d9 via --color-gray-6 |
--color-content-default | semantic every component | #202020 via --color-gray-12 |
--color-content-utility-danger | semantic every component | #ce2c31 via --color-red-11 |
--transition-fast | semantic every component | 150ms via --duration-150 |
--spacing-100 | primitive every component | 0.25rem |
--spacing-200 | primitive every component | 0.5rem |
--spacing-400 | primitive every component | 1rem |