Radio group

ui.css · Cross-system: Cross-system reference § Forms

Overview

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:

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.

Anatomy

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.

Segmented look

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:

  1. One shared frame with hairline dividers between the segments, in the same colour. Drawn from the segments' own borders, not from a border on the <fieldset> — a <legend> is rendered in the group's block-start border area, so a border there has a gap around the legend.
  2. Equal segment widths, from a grid of 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%.
  3. The selected segment filled edge to edge — full height, no inset, no shadow, in a --brand tint rather than --brand itself. A picker painted in the app's primary-action colour reads as a row of buttons.
  4. A check on the selected segment, so selection does not rest on colour alone. Where the segments carry icons it replaces the icon, in exactly the same 1.1em box, so the row never changes width as the selection moves; where they don't, the slot is reserved in every segment and shown only on the selected one. Material uses the same approach for its segmented button.

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.

Show code

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.

Role
Show code

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.

Show code

Card look

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.

Content type
Show code

.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.

Keyboard

The browser provides all the keyboard behaviour below. Using native radios avoids having to implement it with custom buttons.

KeyDoes
TabEnters 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
SpaceSelects 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 / EndNothing. 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>.

States

Show code

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.

Show HTML
Show JS // The two halves of the refusal. The CSS dims the option; this keeps it // from being chosen, which native `disabled` would otherwise do. const group = document.querySelector('#demo-refused fieldset') const refused = (el) => el instanceof HTMLInputElement && el.getAttribute('aria-disabled') === 'true' let chosen = group.querySelector('input[type="radio"]:checked') group.addEventListener('click', (e) => { if (refused(e.target)) e.preventDefault() }) group.addEventListener('change', (e) => { if (refused(e.target)) chosen.checked = true else chosen = e.target })

Clearing the selection

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.

In context

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.

Scope
Applies to every chapter unless you narrow it.
Show code

Theming

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.

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.

Show code

Accessibility

Cross-system

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.

See also