# Radio Let users select exactly one option from a group of mutually exclusive choices. Section: components Docs: https://nebula.kaluza.com/components/radio --- ## When to use - When exactly one option must be selected from a group and no partial selection is valid - When the full set of options should be visible at once, so users can compare before choosing - When there are typically 2 to 7 options (for longer lists, consider Select Field) ## When not to use - **When users can select more than one option** - use [Checkbox](/components/checkbox) instead. Radios imply that only one selection is valid. - **When there are many options** - use [Select Field](/components/select-field) instead. A long radio group is difficult to scan and takes up significant vertical space. - **When there is only one option** - a single radio button makes no sense semantically. Use [Checkbox](/components/checkbox) for a binary on/off choice. - **To show a pre-selected option the user cannot change** - the disabled state is not supported. Display the value as plain text or use a read-only [Summary List](/components/summary-list) instead. Do not use the disabled prop. ## Anatomy 1. **Group label** 2. **Group hint text** 3. **Radio control** 4. **Item label** 5. **Item hint text** 6. **Error text** ## Basic usage Use `RadioField` to group related radio buttons. The `label` prop becomes the group's `legend`, which screen readers announce before each individual option. Pass a `name` prop so all radios in the group share the same form field name. ```tsx ``` ## Variants and features ### With hint text Hint text can be added at the group level to clarify the question, or at the item level to explain a specific option. Add item hint text only when the label alone may be unclear. Avoid adding hints to every option as a matter of course. Group hint text clarifies the question as a whole: ```tsx ``` Item hint text explains a single option that the label alone does not make clear: ```tsx ``` ### Horizontal layout Set `horizontal` on `RadioField` to lay out options side by side. Only use horizontal layout when there are 2 or 3 options, labels are short, and no item hint text is present. For most groups, the default vertical layout is easier to scan. ```tsx ``` ### Optional groups Mark the group as optional with the `optional` prop on `RadioField`. This appends "(optional)" to the group label. Required groups do not need a label suffix. Required is the default expectation. ```tsx ``` ### Common form features For validation, error messages, and integration with the `Form` component, see the [Forms pattern guide](/patterns/forms). ## Rules and guidance ### Usage - **Radio** MUST NOT be used when more than one option may be selected at a time. Rationale: Radios imply mutually exclusive selection. Using them for multi-select misrepresents the input's behaviour and prevents users from selecting more than one valid option. [id: radio.not-for-multi-select; testability: manual] - **RadioField** MUST NOT contain only one Radio option. Rationale: A single radio button cannot be deselected once chosen. This traps the user in a selection with no way to undo it. Use Checkbox for a single binary choice instead. [id: radio.not-for-single-option; testability: auto] - **Radio** MUST NOT be passed the disabled prop. Rationale: The prop passes through to the native input with no Nebula styling and removes the field from the tab order, making it invisible to keyboard users with no accessible indication of why. [id: radio.no-disabled; testability: auto] - **Radio** SHOULD be replaced with plain text. For example in a SummaryList. When displaying a value the user cannot edit. Rationale: A field that looks interactive but cannot be changed is confusing. Plain text communicates the value without implying editability. [id: radio.non-editable-as-text; testability: manual] - **Radio** SHOULD NOT be pre-selected by default unless restoring a value the user previously chose. Rationale: Pre-selecting an option increases the chance that users overlook the question and submit an unintended answer. GDS research consistently shows pre-selection causes errors, particularly on infrequently used forms. Only restore a prior selection when the value was explicitly chosen by this user. [id: radio.no-preselection; testability: context] ### Placement - **RadioField** SHOULD NOT use horizontal layout when there are more than 3 options, labels are long, or any option has item hint text. Rationale: Horizontal layout works only for compact, scannable groups. Long labels or hint text break the alignment and make the group harder to read. Vertical layout is the safe default. [id: radio.horizontal-layout-conditions; testability: manual] ### Content - **Radio group** MUST contain options that are mutually exclusive and collectively cover the valid responses. Rationale: This is the semantic contract of a radio group. If options overlap or the set is incomplete, users may be unable to accurately represent their answer, or may select a misleading option. [id: radio.labels-mutually-exclusive; testability: manual] - **Radio item label** SHOULD be written in sentence case. [id: radio.label-sentence-case; testability: manual] - **Radio group** SHOULD list options in alphabetical order unless a different order more closely matches users' mental model or frequency of selection. Rationale: Alphabetical order has no implied hierarchy and is easy to scan. Frequency-based ordering may be appropriate when one option is chosen by the large majority of users. "None" or "I don't know" options should appear last. [id: radio.alphabetical-order; testability: manual] - **Radio item label** SHOULD NOT end with a comma, semicolon, or full stop. Rationale: Trailing punctuation after radio labels adds visual noise. Labels are not sentences in this context. [id: radio.label-no-trailing-punctuation; testability: manual] - **Radio item hint text** SHOULD NOT be added to every option in a group as a default. Rationale: Hint text on every item increases reading burden without adding value for options that are self-explanatory. Add item hint text only when a specific option needs clarification beyond its label. [id: radio.item-hint-selective; testability: manual] - If the question may have no valid answer for some users: **Radio group** MAY include a "None" or "I don't know" option rather than forcing users to skip the question. Rationale: Where no option genuinely applies, forcing a choice produces inaccurate data. A clearly labelled opt-out preserves data integrity and reduces user frustration. [id: radio.none-option; testability: context] ### Validation - **RadioField error text** SHOULD follow the pattern "Select [a/the] [noun]" when no option has been selected. Rationale: This pattern is direct and tells the user exactly what action to take. Vague messages like "This field is required" do not describe what to select. [id: radio.group-error-message-pattern; testability: manual] - **Radio error text** SHOULD NOT be shown at both the group level and the individual item level simultaneously. Rationale: Showing the same error twice adds noise and implies separate problems. Use RadioField's error prop to surface a single group-level error. [id: radio.group-error-not-duplicated; testability: manual] ### Interaction - **RadioField** WILL set aria-invalid on the fieldset and data-error on child Radio inputs automatically when an error is present. [id: radio.will-aria-invalid; testability: auto] ### Composition - **RadioField** WILL set aria-required on each child Radio input automatically, based on the optional prop. [id: radio.will-aria-required; testability: auto] - **RadioField** WILL propagate its name prop to all child Radio inputs automatically, unless a Radio has its own name prop. [id: radio.will-name-propagation; testability: auto] - **Radio group** MUST be wrapped in a RadioField when two or more Radio components share a common purpose. Rationale: RadioField renders a fieldset and legend, which is the accessible HTML pattern for grouping form controls. Without it, screen readers cannot associate individual radio buttons with their group label. [id: radio.group-requires-radiofield; testability: auto] - **Radio** MUST have a unique id prop. Rationale: The id prop links the native input to its label element via htmlFor. Without it, the label is not programmatically associated with the input, making the radio button inaccessible to screen readers. [id: radio.id-required; testability: auto] ## Accessibility ### In brief Radio is a native radio input with a visible label. A screen reader announces the group label, then the option label, the role radio button and whether it is selected. ### What Nebula provides - A native `input` of type `radio`, hidden by making it transparent, so it is still the control that takes focus and is announced. - Radios that share a `name` form one group, so the arrow keys move between them and select. `RadioField` gives every `Radio` its `name`. - The label is linked to the input, so clicking it selects the radio, and a `hint` is linked as a description, not as part of the name. - `RadioField` wraps the group in a `fieldset` with a `legend` that carries the label and hint. ### Keyboard behaviour A group is one tab stop, reached with `Tab`. The arrow keys move between its options. #### Interactions - **Tab** - Moves focus into the group, to the selected option or the first - **Up, Down, Left or Right** - Moves focus to the next or previous option and selects it ### Accessible design #### Give every group of radios a label The label is read before each option, so "Football" is heard as an answer to "What is your favourite sport?". Use `RadioField` and write a label that says what the user is choosing. WCAG: [1.3.1 Info and Relationships](https://www.w3.org/WAI/WCAG22/Understanding/info-and-relationships.html) [3.3.2 Labels or Instructions](https://www.w3.org/WAI/WCAG22/Understanding/labels-or-instructions.html) #### Put a hint on an option only where the label is not enough A hint is read as a description after the option's name, so a hint on every option makes each one slow to listen to. Leave it off the options that are clear. WCAG: [3.3.2 Labels or Instructions](https://www.w3.org/WAI/WCAG22/Understanding/labels-or-instructions.html) ### Accessible development #### Always pass a unique `id` to each Radio The `id` is what links the label and hint to the input. Without a unique one the radio has no name, or takes the name of another. WCAG: [1.3.1 Info and Relationships](https://www.w3.org/WAI/WCAG22/Understanding/info-and-relationships.html) [4.1.2 Name, Role, Value](https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html) #### If answering is not required, set `optional` on `RadioField` Without it every radio in the group is marked as required to a screen reader, and the label does not say the group is optional. WCAG: [3.3.2 Labels or Instructions](https://www.w3.org/WAI/WCAG22/Understanding/labels-or-instructions.html) #### Always render `Radio` as a direct child of `RadioField` The field sets the `name`, the selected state and the error state on its direct children only. A radio inside a wrapper element misses them and drops out of the group. WCAG: [1.3.1 Info and Relationships](https://www.w3.org/WAI/WCAG22/Understanding/info-and-relationships.html) ### Accessibility testing status Reviewed on 9 October 2026 against v17.0.1. | Test | Status | Tested with | | --- | --- | --- | | Default state | Tested | axe-core via the Storybook test runner (CI) | | Advanced states | Partially tested | axe-core via the Storybook test runner (CI) | | Keyboard navigation | Manually tested | keyboard (reported by the owner) | | Screen reader | Partially tested | VoiceOver with Chrome (reported by the owner) | What each test covers: - Default state: The component as it first appears, before anything is clicked or changed. - Advanced states: Every other state and variant: error, open, selected, each size and style, and hovering. - Keyboard navigation: Whether every function can be reached and used with the keyboard alone, with a visible focus ring. - Screen reader: Whether a screen reader announces what is on screen correctly: the name, the role and any state. What these statuses mean: - Tested: Every check in this row passed in an automated run that repeats on every pull request. - Manually tested: Every check in this row was run once, by a person or with a tool they started, and all of them passed. It is not re-run on every change. - Partially tested: Some checks in this row were run once and passed; the rest are still to do. - Issue found: A check in this row failed. The ticket is shown beside the status. - Not tested: No check in this row has been run yet. - Not applicable: This row does not apply to the component, for the reason given beside it. The table records which checks were run on this component on its own. It is not a statement of WCAG conformance, and it does not cover how the component is used. ## Properties ### Radio Also accepts all props from `HTMLAttributes`, `InputHTMLAttributes`. - `id` (type `string`, required). Unique `id` for the input. Links the label and hint to it. - `label` (type `ReactNode`, required). Text shown next to the radio button. - `hint` (type `ReactNode`, optional). Helper text shown below the label and linked to the input via `aria-describedby`. ### RadioField Also accepts all props from `FieldsetHTMLAttributes`, `HTMLAttributes`. - `name` (type `string`, required). Shared `name` applied to every `Radio` in the group, unless a `Radio` sets its own. - `defaultValue` (type `string`, optional). The `value` of the `Radio` that is selected initially (uncontrolled). - `label` (type `ReactNode`, required). Legend text that labels the group of radio buttons. - `hint` (type `ReactNode`, optional). Helper text shown under the label. - `error` (type `ReactNode`, optional). Error message shown below the group. Marks the group as invalid (`aria-invalid` on the fieldset) and gives its radios the error styling. Falls back to the error for this `name` from the surrounding `Form`. - `optional` (type `boolean`, optional, default `false`). Shows the field as optional rather than required, and stops its radios being marked `aria-required`. - `value` (type `string`, optional). The `value` of the selected `Radio` (controlled). Use with `onValueChange`. - `horizontal` (type `boolean`, optional). Lays the radio buttons out in a row instead of a column. - `onValueChange` (type `(newValue: string | undefined) => void`, optional). Called with the `value` of the radio button the user selects. ## Styles Reference only; you do not need this to use the component. This is the CSS its styled elements use, with theme tokens shown by name. `${...}` is an interpolation (a theme token, a mixin or another value from the code). A `&[prop]` or `@when ` block holds CSS that applies only under that condition; these are notation, not valid CSS. ### radio-item ```css display: flex; flex-wrap: wrap; position: relative; padding: ${space[1]} 0; ``` ### radio-input ```css opacity: 0; width: ${space[5]}; height: ${space[5]}; margin: 0; padding: 0; z-index: 1; cursor: pointer; ``` ### hint-text ```css display: block; width: 100%; padding-left: ${space[7]}; ``` ### radio-label ```css padding-left: ${space[2]}; align-self: center; cursor: pointer; &:before, &:after { content: ''; position: absolute; border-radius: 50%; } &:before { top: ${(space[5] - space[3]) / 2}; left: 0; width: ${space[5]}; height: ${space[5]}; border: ${borderWidths.standard} solid ${colors.border}; background: ${colors.canvas}; } &:after { top: ${(space[7] - space[5]) / 2 + (space[5] - space[3]) / 2}; left: ${(space[5] - space[3]) / 2}; width: ${space[3]}; height: ${space[3]}; background: ${colors.primary}; opacity: 0; } input:checked + &:after { opacity: 1; } input:focus-visible + &:before { ${focusRing} } input[data-error] + &:before, input[aria-invalid] + &:before { border-color: ${colors.error}; border-width: ${borderWidths.heavy}; } ``` ### layout ```css display: flex; flex-direction: ${horizontal ? 'row' : 'column'}; column-gap: ${horizontal ? space[6] : 0}; flex-wrap: wrap; ```