ui.css · Cross-system: Cross-system reference § Forms
A group for choosing one option: a <fieldset role="radiogroup"> of native
<input type="radio"> sharing one name. There is no JavaScript
and no custom element. The browser already implements the
APG radio pattern and provides:
<legend> as the group's accessible name, and the
"radio button, X of Y" announcement that tells a screen-reader user how many options are available.change event, submission of the name/value pair, reset with the
form, and disabled on the fieldset propagating to every control inside it.
A custom role="radiogroup" of buttons has to re-implement all of that, and
can easily omit arrow-key handling or use aria-pressed where
aria-checked belongs. As with the switch, the browser provides the behaviour and CSS changes the appearance.
Native radio buttons also work without these styles. A stacked list of native radios needs none of the attributes or CSS rules described here — data-look="segmented" is an optional style for displaying the options in a row.
Each option is a <label> wrapping its own radio, making the whole segment clickable without for/id wiring. CSS hides the radio visually but it is not removed: it keeps its place in the tab order
and the accessibility tree, so the browser continues to provide the behaviour described above.
Name the group with either a visible <legend> (below), or
aria-label on the fieldset where a visible caption would only repeat what the
options already say — a two-option mode switch in a toolbar, for instance.
Separate the option’s appearance from its behaviour. The CSS uses two rules: [radio-option] keeps the radio invisible, focusable and stretched over
the whole option — behaviour shared by every visual style — while a separate rule defines its appearance. data-look="segmented" below applies
[radio-option] to its own labels without the marker, because the group selector already identifies those labels; anywhere else, put the marker on the label. For an app-specific pill or chip style, combine your own class with
[radio-option], instead of re-implementing the hiding technique. A plain
stacked list of native radios needs neither.
The bare attribute describes how the elements work together — it makes the label its radio's hit area, independently of their appearance
(Modifiers § Why composition is an attribute, and
bare).
Declare role="radiogroup" on the fieldset. This is the one part of
the pattern the platform does not infer: a bare <fieldset> maps to
role="group", and containing radios does not upgrade it. The keyboard still works
without it — the browser groups radios by their shared name, not by their
container, so arrow keys and "X of Y" are unaffected — so the omission is easy to miss. The group is announced as a generic group and
cannot be found as a radio group by assistive tech or by a test.
APG asks for it, and Chromium and
Firefox both map the bare fieldset to role="group", while retaining keyboard navigation.
data-look="segmented" lays the options out as one control divided into segments — a segmented button, in the sense Material, Apple and Carbon all use the
term (Cross-system reference § Segmented button
compares the systems’ specifications). Four CSS features give the row a unified appearance, without additional markup:
<fieldset> — a <legend> is rendered in the group's
block-start border area, so a border there has a gap around the legend.1fr columns: every segment
is as wide as the widest one's content, and the row cannot wrap. The group is
fit-content, so its width follows its content. To span the pane, set inline-size: 100%.--brand tint rather than --brand itself. A picker
painted in the app's primary-action colour reads as a row of buttons.
The frame belongs to the segments, not to the <fieldset>.
A <legend> is rendered by the UA inside the group's block-start border area,
so a border declared on the group has a gap around the caption. This affects the default --radius-2, and is especially noticeable with a pill shape set through --segmented-radius. The group therefore carries
border: 0, and the row is drawn from the segments themselves:
border-block on every one, the outer edge on the first and the last, and one
border-inline-start per adjacent pair as the hairline between them. The group’s border-radius shapes its focus ring. The end segments use the same value for their outer corners, keeping the row and ring aligned.
When changing the layout, keep the segments touching:
gap: 0 is declared rather than left at the grid's initial value, because a group
composed with .form-group would otherwise inherit that column's
var(--size-1) gap and make the segments look like separate buttons. Hide the divider beside the selected segment by setting its border colour to transparent. Keep its width unchanged so the row does not shift by a pixel when the selection changes. The selected segment's fill provides the visible edge.
The selected state is read straight off :checked through :has(), so it
cannot drift out of sync with the control's real value.
Two to five options, each with a short text label. Beyond five, use
<x-select>; for switching whole sections of the app,
tabs. You can include an icon alongside a label (below), but icon-only segments are unsupported: the selection check would replace the option’s only visible label.
With a visible caption, add a <legend>. It appears above the row automatically:
the browser renders it as the fieldset's caption, outside the group's formatting context, so it
is not a grid item (nor a flex one) and needs no layout rule or wrapper. This explains two potentially surprising results: setting
grid-column: 1 / -1 on the legend to make it span has no effect, and a
border on the fieldset would be interrupted by the caption — which is why the
frame is drawn by the segments instead. Both hold in Chromium and Firefox alike.
Options support icons just as buttons do. Mark each icon
aria-hidden="true" — <ui-icon> does this itself — so the visible
text remains the accessible name. On the selected segment the icon is replaced by the check
(the fourth feature above), which is why the icon needs a text label beside it. Icons can use either inline <svg> or <ui-icon>; the selector :is(ui-icon, svg) handles both. The CSS checks for icons across the whole group. It reserves checkmark space in every segment only when the group has no icons, so supply icons for all segments or for none. In a
row that mixes the two, an option without an icon has no reserved space for its checkmark, so selecting it changes the row’s width.
When each option needs a line of description, use cards: a segmented row has room only for icons and names. .option-card displays the options as cards: <label class="option-card" radio-option>
around the radio, an icon, a <span> for the name and a
<small> for the description. Set the group’s layout separately, using a grid or column as appropriate.
.option-card also supports
<button class="option-card" aria-pressed>, for a choice that is
allowed to have no selection or multiple selections — see
Clearing the selection. The look is identical; only the control
underneath differs, so choose the control according to how many options users can select.
A shared selector reads :checked and aria-pressed, giving both control types the same selected appearance.
Tint with --_tint (defaults to --brand) to colour one group's
selected border, fill and text differently.
The browser provides all the keyboard behaviour below. Using native radios avoids having to implement it with custom buttons.
| Key | Does |
|---|---|
| Tab | Enters the group once — onto the checked option, or the first one when nothing is checked. The next Tab leaves the group entirely |
| ← → ↑ ↓ | Moves to the previous / next option and selects it — selection follows focus, which is what distinguishes a radio group from a listbox. Wraps at both ends. All four arrows work regardless of how the group is laid out, and a disabled option is skipped |
| Space | Selects the focused option. This only changes the value when nothing in the group is checked yet — once selection follows focus, the focused option is already the selected one |
| Home / End | Nothing. They are not part of the APG radio pattern, and no engine implements them here — Chromium and Firefox alike. These keys do work in listbox() and <ui-toolbar>, which can make those alternatives easier to navigate when there are many options |
Selection follows focus, so there is no "browse" mode. A keyboard user cannot
move through the options to look at them without also choosing them. That is correct for a
choice that is cheap to change, and wrong for one with an immediate side effect that is
expensive or hard to undo — sending, deleting, starting a job. For those, use a
listbox, where Enter commits separately, or put the choice
behind an <x-select>.
checked on one radio. Selecting another radio clears the previous selection.disabled on the
<fieldset>. The platform propagates it to every radio, so the whole group
dims and drops out of the tab order.disabled on one radio. The arrow keys skip
it, natively.aria-disabled="true" on one radio, for a
choice that has to stay reachable so it can say why it is unavailable. Dimmed like a disabled
one, but only that option, and your own handler has to refuse it. See
Refusing one option.
Prefer native disabled. It is the conventional choice and the
browser enforces it for you — the option leaves the tab order, the arrow keys skip it, and a
screen reader announces it as unavailable. Use it for a choice that is simply not there, and
for the whole group when none of the options can be chosen.
Use aria-disabled="true" for a choice that has to explain itself.
A disabled radio cannot: the arrow keys never land on it, so a keyboard user never reaches the
explanation, and it is not the element a hover or focus tooltip can hang on. An
aria-disabled radio keeps its place in the arrow-key walk and remains a tooltip's
trigger. The price is that the browser stops refusing the choice, so the page has to.
Refuse it in your own handler, in two places: cancel click, which
covers the pointer, Space and an arrow key that an assistive technology routes
through a click; and on change, put the previously checked radio back, for a
selection that arrives by any other route. Give the reason on the radio — a
title, or a tooltip — because aria-disabled announces the state and
nothing else.
The dimmed look is per option. The disabled row above dims as soon as any radio in it
is disabled, which is what a row nobody can use needs and the wrong answer for one choice out
of three, so aria-disabled on a radio dims that segment alone.
Users cannot clear a native radio group after selecting an option. A radio group may not return to empty once one of its radios is checked, and there is no ARIA state that tells a screen reader a group is clearable. Script that unchecks the radio on a second click therefore conflicts with the announced semantics: the user is told "radio button, not checked, 1 of 4" even though radio groups are expected to retain a selection.
A group that allows zero or one selection is a different widget:
role="group" with <button aria-pressed> options, the
Toggle Group row of the
Cross-system reference § Actions. This pattern supports a "nothing pressed" state, but requires custom roving tabindex and arrow-key handling.
Before reaching for either, consider whether "none" is really an option rather than an absence — All, Any, Off. A filter with an "All" option can use a standard radio group. The option explicitly describes the unfiltered state and is keyboard-accessible like every other option. The app currently uses this approach.
Inside a form, the group is a <fieldset class="form-group"> with its caption
as the <legend>. A <label for> cannot be used here: a
group is not a labelable element, so for has nothing to bind to. Help text and
per-option descriptions go in a sibling after the group, not inside it.
The group uses the app's design tokens, so its theme updates automatically — including dark mode,
which is handled centrally in index.css by re-defining the
semantic tokens. There is no radio-group-specific dark-mode CSS.
The browser draws a radio outside a segmented group. It uses
accent-color: var(--brand) to tint the native control while retaining its browser-provided appearance.
To restyle only a segmented group in a given scope, override these custom properties. Set them on the group or on any ancestor; both are picked up.
--segmented-outline — the frame and the dividers, which are one colour by
design. Default var(--border-color).--segmented-selected — fill of the selected option. Default
color-mix(in oklab, var(--brand) 20%, var(--surface-1)). Mix in
oklab, not oklch, when the other colour is a near-neutral
surface: its hue still affects the result, and engines interpolate it differently — the same mix comes
out at hue 272° in Chromium and 140° in Firefox, i.e. blue against green.--segmented-on-selected — text (and the check) on that fill. Default
var(--text-1). Set it whenever you set a strong
--segmented-selected, to keep the text and checkmark readable against a stronger fill.--segmented-radius — the row's end corners. Default
var(--radius-2), the control radius Shape gives
.input and .btn ;
var(--radius-round) rounds it fully.--segmented-track — the surface behind the options, visible only where no
segment is filled. Default transparent.
Use color-mix(in oklab, …) whenever the other color is an opaque, near-neutral surface. Such a surface can retain a slight hue even when it looks gray, and engines can interpolate that hue differently in oklch. oklab avoids that difference while remaining perceptual. Mixing with transparent is safe in either space because the transparent color's hue does not affect the result. Check the second color before choosing the space.
The suite enforces this, so you do not have to remember it.
test/z-style.test.mjs § a color-mix into an opaque colour is in oklab, not
oklch sweeps every .css, .html and .mjs file in the
tree and fails on any oklch mix whose other colour is not
transparent. It reads one line at a time, so it cannot detect mixes split across lines.
aria-checked, tabindex or keydown to write, and
nothing to keep in sync.<legend>, or
aria-label on the <fieldset>. Without one, a screen reader
announces the options but never what the choice is about. <label for> is
not an option: a group is not labelable.role="radiogroup" — a <fieldset> is
role="group" on its own, and the radios inside do not change that. The omission does not affect appearance or keyboard operation, so visual review alone will not catch it; see Anatomy.opacity applies to an element's outline too, so the focused radio's own ring is
exactly as invisible as the radio. The visual style must therefore provide a replacement focus ring. A segmented group
draws it round the whole row, since the row reads as one control; a
card draws it round the card, which is the option. Any
other style must also provide a focus ring — [radio-option] deliberately does not supply one,
because its placement depends on the visual style.prefers-reduced-motion: reduce, and under Windows High Contrast
(forced-colors: active) the painted fill is replaced by
Highlight/HighlightText, because the fill itself is discarded there
and every option would otherwise look identical.disabled on the
<fieldset> over aria-disabled plus a
pointer-events block on a wrapper. This disables the controls fully:
out of the tab order, announced as unavailable, and not merely unresponsive to a click. Pair
it with a title saying why. Disabled controls are exempt from WCAG contrast, so
the reduced opacity is fine.
disabled takes that
option out of the arrow-key walk, so a keyboard user never reaches the explanation, and it
leaves no element for a tooltip to hang on. Mark that one radio
aria-disabled="true" instead, name the reason on it, and refuse the choice in
your own handler — the same trade the switch offers, and the
APG's
focusable-disabled-control guidance. The library dims both.
See Cross-system reference § Forms for mappings to the
Open UI radio-button research, Material md-radio, Radix
Radio Group, Bootstrap "Radios", and the WAI-ARIA APG
Radio Group pattern — and § Actions for Toggle Group, the
zero-or-one counterpart discussed under Clearing the selection.
role="switch" — the same native-control-plus-CSS approach for a single boolean.<x-select> — the same choice as a dropdown, for when the options are many or the row would not fit.listbox() — pick one from a list where focus and selection must move separately..form-fields — the .form-group layout the in-context demo uses.disabled / readonly / validation vocabulary.article — Card — the content card. Unrelated to
.option-card above, which is an option in a picker
rather than a list item.