Radio and RadioGroup
Tier A. Follows the anatomy template and the conventions.
1. Purpose and native element
Section titled “1. Purpose and native element”Picks one option from a set. Radio renders a native <input type="radio"> with a <label>. RadioGroup
renders a <fieldset> with a <legend> and gives all its radios one name. It follows the WAI-ARIA
Radio Group pattern. The native <fieldset> has the
group role, and the grouping and keys come from the browser, so no role="radiogroup" is added.
A set where several options may be on is a group of Checkboxes. A set that acts like buttons is a ButtonGroup.
2. Object
Section titled “2. Object”A round well with a knob (the dot) when selected. The well’s base is the input’s border box.
| Part | Shape | Height at 50% | Moves |
|---|---|---|---|
| Well | Circle (radius.pill) sunk by elevation.well (2) |
−elevation.well |
No |
| Dot | Sphere of diameter box − 4 · object.taper (12 at the default), centred |
elevation.raised (3) |
Rises from the well floor on select, sinks when another radio is chosen |
The input’s border box is size.indicator square: 16, 20 (default) or 24 px by data-strata-size (section
13), and the label row is at least target.min (24) high and covers the whole row, so the row is the target (WCAG 2.5.8). The dot is half the box. The dot is centred and well inside the box.
The section 6 vocabulary lists “Checkbox, radio: small well with a raised mark”. Radio uses the knob
primitive for the dot, as the README index says: a round dot reads as a sphere, not a glyph.
3. Faces and materials
Section titled “3. Faces and materials”| Face | Colour role | Text role | Material role |
|---|---|---|---|
| Well floor | field |
— | field (matte) |
| Dot (selected) | accent |
— | indicator (emissive) |
| Well outline | edge, 1 px |
— | None |
| Label | None (sits on the panel) | text |
None |
| Legend | None | text |
None |
The well’s edge outline keeps its shape at 3:1 against the panel, and the dot against the well floor keeps
at least 3:1 (non-text contrast, section 8).
4. Elevation per state
Section titled “4. Elevation per state”| State | Relief | Objects | CSS |
|---|---|---|---|
| Rest, unselected | Round well at −elevation.well; no dot |
Empty well | Inset shadow |
| Hover | Interior +state.hoverLift (clamped by interior.max) |
Same | brightness(1.04) |
Pressed (:active) |
Interior −state.pressSink |
Same | brightness(0.96) |
| Focus-visible | Accent edge, focus.width, outside the circle |
Emissive glow on the well | outline: focus.width solid accent |
Selected (:checked) |
Dot emissive at elevation.raised, motion.fast |
Dot rises | Accent dot |
| Disabled | Well and dot at height 0, content at 38% | Flattened to the face | Flat, opacity: 0.38 |
Invalid (aria-invalid="true") |
danger edge on every well in the group |
Same | outline in danger |
Busy does not apply. Under prefers-reduced-motion, the dot appears with no rise.
5. Relief primitives
Section titled “5. Relief primitives”Two per radio: the <input> is a well (circle); the dot is a knob, drawn from a child
<span data-strata-face="knob"> shown when :checked. The <fieldset> has no primitive.
6. Tokens it reads
Section titled “6. Tokens it reads”| Token | Prop |
|---|---|
colour.field, colour.edge, colour.accent, colour.text, colour.danger |
— |
radius.pill |
— (theme or scope) |
elevation.well, elevation.raised, object.taper, state.*, interior.max |
— |
material.roles.field, material.roles.indicator |
material |
target.min (box size), density (label row height), space.* |
— |
font.size.body, font.weight.strong (legend) |
— |
focus.width, focus.offset, motion.fast |
— |
level |
level |
7. DOM structure and overlay needs
Section titled “7. DOM structure and overlay needs”<fieldset class="ug-radio-group" aria-describedby="plan-help plan-error"> <legend class="ug-radio-group__legend">Plan</legend> <label class="ug-radio"> <input class="ug-radio__input" data-strata-face="well" type="radio" name="plan" value="free" checked aria-invalid="true" /> <span class="ug-radio__dot" data-strata-face="knob" aria-hidden="true"></span> <span class="ug-radio__label">Free</span> </label> <label class="ug-radio"> <input class="ug-radio__input" data-strata-face="well" type="radio" name="plan" value="pro" aria-invalid="true" /> <span class="ug-radio__dot" data-strata-face="knob" aria-hidden="true"></span> <span class="ug-radio__label">Pro</span> </label> <p class="ug-field__help" id="plan-help">You can change it later.</p> <p class="ug-field__error" id="plan-error">Error: choose a plan.</p></fieldset>- The
<fieldset>is the group’s boundary element; each<input>is a radio’s. Layout (row or column) is a CSS prop and never changes the DOM order, which sets the arrow-key order. - The wrapping
<label>extends the hit target to the whole row, at least the density’s control height. - The group owns
name,valueandonChange.requiredon the radios makes the group required natively. aria-invalid="true"goes on each radio. The message is in the fieldset’s description.disabledon the<fieldset>disables every radio natively.- No overlay.
8. Keyboard and screen reader
Section titled “8. Keyboard and screen reader”| Key | Action |
|---|---|
| Tab, Shift+Tab | Enter or leave the group, landing on the selected radio (or the first, if none) |
| Arrow Down, Right | Select the next radio, wrapping (native) |
| Arrow Up, Left | Select the previous radio, wrapping (native) |
| Space | Select the focused radio, if not already selected |
- Role
radio(native), inside thegroupof the<fieldset>, named by the<legend>. The browser announces the position (“2 of 3”) and the checked state. - Disabled radios inside an enabled group are skipped by the arrow keys, as native.
- Home and End are not part of native radio behaviour and Strata does not add them.
9. CSS presentation
Section titled “9. CSS presentation”The well recipe as a circle, the dot as a sphere shaded from the light, the dot in accent. The box is
size.indicator square and the row at least the density’s control height. Legend and options sit on space.2 gaps.
Under forced-colors: Field background, a ButtonBorder border, the dot in FieldText, GrayText
when disabled, a Highlight focus outline. The dot’s presence shows the state.
10. Tests
Section titled “10. Tests”- Plain DOM: click on the circle and on the label selects; arrows move and select with wrap; Tab enters once and lands on the selected radio; form data holds the selected value; disabled radios are skipped.
- GPU: at Relief and Objects, GPU stays active through selecting and arrowing; pixels are fresh (the old dot gone, the new one present); a hit at the circle’s edge pixel and on the label reaches the input.
- Accessibility: axe on selected, unselected, disabled and invalid, in light, dark and high contrast; accessibility snapshot equal in DOM and GPU modes (group name, checked, position); focus ring in decoded pixels; dot contrast against the lit well floor.
- Parity: Relief and CSS within the P0.6 budget at rest, hover, focus and selected.
11. Hardware forms: the preset bank (bank) and the rotary selector (selector)
Section titled “11. Hardware forms: the preset bank (bank) and the rotary selector (selector)”Instrument, everyday forms (plan). form="bank" on a RadioGroup draws the
same native radios as a preset bank: interlocking latching buttons on one plinth, exactly one down at a time (the radio buttons of
a car radio, where the name comes from), each label printed on its cap, and an LED above the key that is down. The group’s <legend>
stays on the faceplate above the plinth. flush, or no form, is the group above, in the markup and the look it had before, pixel for pixel;
any other form string is the native form attribute of the fieldset, as before. form="selector" is the other hardware form (below). colour names the LEDs’ role. As with the Switch’s hardware forms, the premium character’s default form is reached by naming it: with no form the control is its flush look, so a page that does not opt in is unchanged.
| Part | Shape | Moves |
|---|---|---|
| Plinth | One polished part, ug-bank__plinth: a rounded box 2 px larger than the keys all round, 2.4 high at 50% depth, on a fieldset with 8 px of padding for its shadow |
No |
| Key | A button-sized cap (control height, at least target.min: 28, 34 or 40 px high), radius.control, as wide as its label plus 32 px (at least 24), on a body 5 high with the captured label. The key’s box (the native <input>’s) is the cap plus 6 px each side and 14 above, so neighbours touch and the hit areas leave no gap |
Travel 4 px at 50% depth: 0.45 while selected, 1 while held, 0 at rest |
| LED | A 6 px glass lens (8 px socket) on the plinth, centred above the cap | Lit 0 to 1 by --strata-led: lit while selected |
- Interlocking. The native radio group is the interlock: one
name, one selected, the others up. Selecting another key brings the one that was down back up (both are CSS numbers on their own key, so both are uniform writes). Tab lands on the selected key (or the first), the arrow keys move and select (wrapping, skipping a disabled key, onechangeper real selection), Space selects, exactly as the group above. - Cues. The selected key is down, its LED is lit, and
checkedis native; under more contrast and forced colours it also has a bar under its label (Highlightfill in forced colours). A disabled key is never held or lit, and a selected disabled one stays down. A group with nothing selected has every key up and every LED dark (a radio is:indeterminatethen, and the form does not treat it as mixed). - The native controls stay the controls. Each
<input type="radio">covers its key (opacity: 0,z-index: 1) inside its<label>, whose text is the cap’s label (the accessible name); the group is the<fieldset>, named by its<legend>. The plinth, the LEDs and the keys’ bodies are decoration (aria-hidden,pointer-events: none). - Emitters. Each key’s LED is an emitter at every GPU level (
emitter.led.*), so a bank has one per key; there is no ring. At the Instrument level the plinth is an object of its own (onebezelpart in the polished bezel material, its shadow fitted to the fieldset’s box), and each key an object of a body, a captured cap and an LED lens (pushObjectwith abaseon the plinth). Relief and Objects: the plinth a raised face or a slab, the caps raised faces or keys standing on it. This is the one construction for keys on a plinth (gpu/instrument-bank.ts): the SegmentedControl’s latching form and the Tabs’ rail are built with it too, and a bank key’s body is registered with the key registry, so the scene can trim it to core’s material cap. - Presentation. As the Checkbox’s (section 11 of its page), with the bank wrapping to rows in a narrow viewport (320 px) or at 200% text, the caps growing and wrapping their labels.
<fieldset class="ug-radio-group" data-strata-form="bank"> <legend class="ug-radio-group__legend">Preset</legend> <div class="ug-bank__plinth" data-strata-face="raised"> <label class="ug-radio"> <span class="ug-push ug-latch ug-latch--bank"> <input class="ug-radio__input" type="radio" name="preset" value="piano"> <span class="ug-push__object" aria-hidden="true"> <span class="ug-push__led-socket"></span> <span class="ug-push__led"></span> </span> <span class="ug-push__cap" data-strata-face="raised"><span class="ug-radio__label">Piano</span></span> </span> </label> <label class="ug-radio"> <span class="ug-push ug-latch ug-latch--bank"> <input class="ug-radio__input" type="radio" name="preset" value="organ"> <span class="ug-push__object" aria-hidden="true"> <span class="ug-push__led-socket"></span> <span class="ug-push__led"></span> </span> <span class="ug-push__cap" data-strata-face="raised"><span class="ug-radio__label">Organ</span></span> </span> </label> <label class="ug-radio"> <span class="ug-push ug-latch ug-latch--bank"> <input class="ug-radio__input" type="radio" name="preset" value="strings"> <span class="ug-push__object" aria-hidden="true"> <span class="ug-push__led-socket"></span> <span class="ug-push__led"></span> </span> <span class="ug-push__cap" data-strata-face="raised"><span class="ug-radio__label">Strings</span></span> </span> </label> </div></fieldset>- The group’s
helpanderrorstay below the plinth (in the fieldset’s description), and the group’srequiredand anerrormark every radio, as above. attachStrataPushButtonsadds only the held state and the feedback (clickonce per real selection, including an arrow key’s). React:<RadioGroup form="bank" colour="success" legend="Preset" value onChange>; Vue: the same withv-model; Astro renders the radios fromoptions.- Tests:
ui-latching-forms.test.mjs,ui-latching-forms-bindings.test.mjs,ui-latching-forms-astro.test.mjs.
The rotary selector (selector)
Section titled “The rotary selector (selector)”form="selector" draws the same native radios as the RotarySelector’s dial: one detent per radio, each radio’s own label printed
round the dial at its position, a cap whose pointer turns to the checked one on spring.detent. The group’s <legend> stays above it. The native radios stay
the control: each <input type="radio"> lies inside its printed label’s text as an invisible overlay at least 24 px square, so a click on a printed
position is a click on that radio, and Tab, the arrow keys (which move and check, wrapping, skipping a disabled radio) and Space are the group’s, exactly
as above; aria is unchanged (the fieldset and its legend name the group, each label its radio) and the dial, the ticks and the numbered legend are aria-hidden
decoration. A group with nothing checked has its cap at the first position and no print selected.
<fieldset class="ug-radio-group" data-strata-form="selector"> <legend class="ug-radio-group__legend">Mode</legend> <div class="ug-rotary ug-rotary--group" style="--strata-rotary-value: 0;"> <div class="ug-rotary__dial"> <span class="ug-rotary__scale"> <label class="ug-rotary__mark" style="--strata-rotary-at: 0;" data-strata-value="mix" data-strata-selected="false"><span class="ug-rotary__tick" aria-hidden="true"></span><span class="ug-rotary__text"><input class="ug-rotary__radio" type="radio" name="mode-example" value="mix">Mix</span></label> <label class="ug-rotary__mark" style="--strata-rotary-at: 0.3333;" data-strata-value="solo" data-strata-selected="false"><span class="ug-rotary__tick" aria-hidden="true"></span><span class="ug-rotary__text"><input class="ug-rotary__radio" type="radio" name="mode-example" value="solo">Solo</span></label> <label class="ug-rotary__mark" style="--strata-rotary-at: 0.6667;" data-strata-value="mute" data-strata-selected="false"><span class="ug-rotary__tick" aria-hidden="true"></span><span class="ug-rotary__text"><input class="ug-rotary__radio" type="radio" name="mode-example" value="mute">Mute</span></label> <label class="ug-rotary__mark" style="--strata-rotary-at: 1;" data-strata-value="bypass" data-strata-selected="false"><span class="ug-rotary__tick" aria-hidden="true"></span><span class="ug-rotary__text"><input class="ug-rotary__radio" type="radio" name="mode-example" value="bypass">Bypass</span></label> </span> <span class="ug-rotary__skirt" data-strata-face="raised" aria-hidden="true"><span class="ug-rotary__cap" data-strata-face="raised"><span class="ug-rotary__pointer"></span></span></span> </div> <ol class="ug-rotary__legend" aria-hidden="true"> <li data-strata-selected="false">Mix</li> <li data-strata-selected="false">Solo</li> <li data-strata-selected="false">Mute</li> <li data-strata-selected="false">Bypass</li> </ol> </div></fieldset>- The Select’s limits. The same as the Select’s selector: a dial’s labels sit on a 270° arc, which fits at most 8 radios of at
most 12 characters. A group asked as a selector that does not fit (nine radios, a longer label, one radio, or radios a binding cannot read: a child that is not a
Radio, a label that is not plain text) is drawn as abank, which keeps the hardware face and takes any group;data-strata-formsays which. React and Vue read theRadiochildren (directly or through fragments), Astro itsoptions. - Gestures. The radios take their own clicks and the keys. The dial adds what they do not cover, each ending in a radio’s native
click()(soinputandchangefire as for a click on it): a click on the ring where no label is checks the radio nearest that angle; a click on the skirt or cap steps to the next radio on its upper half and the previous one on its lower half (the single-pointer alternative, WCAG 2.5.7); the wheel over the dial steps one radio and the page does not scroll. There is no drag: the dial is the group’s decoration, and a vertical drag over radios has no native meaning. A changed radio playstickthroughstrataFeedback(off unless the page or scope turns it on). - Presentations. The RotarySelector’s, unchanged: its CSS (
ug-rotary__*,ug-rotary--groupon the root; at under 104 px + 7em the positions are numbers on the dial and the labels a numbered legend under it, for 320 px and 200% text), its Relief and Objects faces, and at the Instrument level the Knob’s object (gpu/instrument.ts) over the dial’s box, turned to the checked radio onspring.detent; a turn is a uniform update with no capture and no hit-geometry update, and the printed labels (captured content) change colour with the selection, as the Select’s do. The group’s<fieldset>is the native element the object lies in (its box holds the dial, as the bank’s holds its plinth), so a click on a part lands on the group and, where a label is, on its radio. - Props.
form,sweep(the cap’s turn from the first to the last radio in degrees;knob.sweep, 270). React and Vue keep the group controlled withvalueandonChange(v-model) or uncontrolled withdefaultValue; a selector keeps its own value when uncontrolled so the dial follows it. - Tests:
ui-deferred-forms.test.mjs,ui-deferred-forms-bindings.test.mjs,ui-deferred-forms-astro.test.mjs,strata-deferred-forms.unit.test.mjs.