Radio Group
stimeo--radio-group
Radio buttons with a look of your own: chosen with the arrow keys, and one Tab stop in all.
Builds a pick-one control out of your own markup, such as selectable cards. Which one is chosen is announced. The whole group is a single Tab stop, so a long list never becomes a long walk from the keyboard. The arrow keys move and choose in one step and wrap around at the ends, and Home and End jump to them. The chosen value is mirrored into a hidden field so your form submits it as usual. You are told only when the choice really changed.
Tab into the group, then Space selects the focused plan; arrow keys move (and select).
Selected plan: (None selected)
Keyboard
| Key | Action |
|---|---|
| ↓ / → | Move to and select the next radio (wraps). Under RTL only the horizontal pair swaps. |
| ↑ / ← | Move to and select the previous radio (wraps). Under RTL only the horizontal pair swaps. |
| Home / End | Move to and select the first / last radio. |
| Space | Select the focused radio. Arrows move and select together, so this is how the first choice is made in a group that starts with nothing selected. |
<%# 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 group
delegates click and keydown, so the radios carry no data-action of their own. 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>
<p class="radio-group-demo__hint"><%= t("components.radio_group.demo.hint") %></p>
<div class="radio-group-demo__options">
<div class="radio-group-demo__option" role="radio" aria-checked="false" tabindex="0"
data-value="basic" data-stimeo--radio-group-target="radio">
<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">
<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">
<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="" 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.none") %></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__hint {
font-size: 0.8125rem;
color: var(--color-text-muted);
}
.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
|
Optional direct binding for Arrow/Home/End/Space navigation; group delegation works without it. | stimeo--radio-group#onKeydown |
select
|
Optional direct binding that selects the clicked radio; group delegation works without it. | stimeo--radio-group#select |
Events
| Name | Description | Event |
|---|---|---|
change
|
Dispatched only when the reader changes the selected radio, with the value and radio in detail. | stimeo--radio-group:change |
reconcile
|
Fires when target churn or duplicate checked state makes the group re-decide the selection; radio is null when nothing is selected. | stimeo--radio-group:reconcile |
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). |