Radio Group
stimeo--radio-group
Single selection for custom radios: aria-checked, roving tabindex, arrow selection.
The stimeo--radio-group controller implements the APG Radio Group pattern for custom (non-native) radios such as styled cards. Selection is exposed through aria-checked and the single Tab stop through roving tabindex. Per APG, selection follows focus: the arrow keys move focus and select in one step (wrapping), and Home/End jump to the ends. The selected radio's value is mirrored to an optional hidden field, and stimeo--radio-group:change is dispatched on every change.
Selected plan: Basic
Keyboard
| Key | Action |
|---|---|
| ↓ / → | Move to and select the next radio (wraps). |
| ↑ / ← | Move to and select the previous radio (wraps). |
| Home / End | Move to and select the first / last radio. |
| Space | Select the focused radio. |
<%# Markup for the radio-group (APG Radio Group) demo.
A custom card-style radio. Single selection is shown via aria-checked and the single
tab stop via roving tabindex. Arrows move the selection (focus = selection). The
selected value is reflected into a hidden field; the look is the consumer's CSS. %>
<div class="radio-group-demo" role="radiogroup" aria-labelledby="rg-label"
data-controller="stimeo--radio-group">
<span class="radio-group-demo__legend" id="rg-label">
<%= t("components.radio_group.demo.legend") %>
</span>
<div class="radio-group-demo__options">
<div class="radio-group-demo__option" role="radio" aria-checked="true" tabindex="0"
data-value="basic" data-stimeo--radio-group-target="radio"
data-action="click->stimeo--radio-group#select
keydown->stimeo--radio-group#onKeydown">
<span class="radio-group-demo__name"><%= t("components.radio_group.demo.basic") %></span>
<span class="radio-group-demo__desc"><%= t("components.radio_group.demo.basic_desc") %></span>
</div>
<div class="radio-group-demo__option" role="radio" aria-checked="false" tabindex="-1"
data-value="pro" data-stimeo--radio-group-target="radio"
data-action="click->stimeo--radio-group#select
keydown->stimeo--radio-group#onKeydown">
<span class="radio-group-demo__name"><%= t("components.radio_group.demo.pro") %></span>
<span class="radio-group-demo__desc"><%= t("components.radio_group.demo.pro_desc") %></span>
</div>
<div class="radio-group-demo__option" role="radio" aria-checked="false" tabindex="-1"
data-value="max" data-stimeo--radio-group-target="radio"
data-action="click->stimeo--radio-group#select
keydown->stimeo--radio-group#onKeydown">
<span class="radio-group-demo__name"><%= t("components.radio_group.demo.max") %></span>
<span class="radio-group-demo__desc"><%= t("components.radio_group.demo.max_desc") %></span>
</div>
</div>
<input type="hidden" name="plan" value="basic" data-stimeo--radio-group-target="field" />
<p class="radio-group-demo__status" aria-live="polite">
<%= t("components.radio_group.demo.selected") %>
<span data-radio-value><%= t("components.radio_group.demo.basic") %></span>
</p>
</div>
/*
* Presentation-only styles for the radio-group demo.
* Selection is expressed via [role="radio"][aria-checked="true"] and focus via :focus-visible.
*/
.radio-group-demo {
display: flex;
flex-direction: column;
gap: 0.5rem;
max-width: 26rem;
}
.radio-group-demo__legend {
font-weight: 600;
}
.radio-group-demo__options {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.radio-group-demo__option {
display: flex;
flex-direction: column;
gap: 0.15rem;
padding: 0.625rem 0.875rem;
background: var(--bg);
border: 1px solid var(--border-interactive);
border-radius: 0.5rem;
cursor: pointer;
transition: border-color 0.15s ease, background 0.15s ease;
}
.radio-group-demo__option[aria-checked="true"] {
border-color: var(--accent);
background: var(--color-primary-soft);
}
.radio-group-demo__option:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.radio-group-demo__name {
font-weight: 600;
}
.radio-group-demo__desc {
font-size: 0.85rem;
color: var(--color-text-muted);
}
.radio-group-demo__status {
margin: 0;
font-size: 0.85rem;
color: var(--color-text-muted);
}
// Demo script that subscribes to the radio-group change event and shows the selected plan name.
// The name is read from the localized text inside the card.
document.addEventListener('stimeo--radio-group:change', function (event) {
const out = document.querySelector('[data-radio-value]');
const name = event.detail.radio.querySelector('.radio-group-demo__name');
if (out && name) out.textContent = name.textContent.trim();
});
These demo styles use shared design tokens (light + dark). Copy the shared styles too, then toggle data-theme on your root element for dark mode.
The data-* attributes you add to your own HTML to wire this component. Put the data-controller below on a root element, then place its targets / values / actions inside that element.
On the root element
data-controller="stimeo--radio-group"
Targets
| Name | Description | Attribute |
|---|---|---|
radio
required
|
A custom radio option (role=radio); selection shows via aria-checked. |
data-stimeo--radio-group-target="radio" |
field
|
Optional hidden input mirroring the selected radio's data-value. |
data-stimeo--radio-group-target="field" |
Actions
| Name | Description | Action |
|---|---|---|
onKeydown
|
Arrow/Home/End/Space navigation with selection-following-focus (wraps). | stimeo--radio-group#onKeydown |
select
|
Selects the clicked radio. | stimeo--radio-group#select |
Events
| Name | Description | Event |
|---|---|---|
change
|
Dispatched on selection change, with the value and the radio in detail. | stimeo--radio-group:change |
State hooks
The library only manages these ARIA/data attributes and custom properties. Your CSS reads them to render the look — selectors like [aria-selected], [aria-expanded], or var(--stimeo--…) hook into this state.
| Hook | Target | Meaning |
|---|---|---|
aria-checked |
Radio | "true" on the selected radio only. |
tabindex |
Radio | 0 on the selected (or first) radio, -1 on the rest (roving). |