react.form.radio-group
Choose exactly one of several radios, with item hints, an "or" divider and inline yes/no (GOV.UK radios).
React ^19 · TypeScript only A React component: use it from a TypeScript project with React installed. A client component ("use client").
1.0.0 (not the latest) · published 2026-10-03 by charlie · Anterra
Pinned by 14 tests, run in TypeScript.
What it does
A question answered by choosing exactly one of a few options - "Where do you live?", "Have you changed your name?" - inside a fieldset whose legend asks it. It is the GOV.UK Design System radios component: item hints, an "or" divider before an option, inline radios for a short yes/no, and small radios.
"use client";
import { RadioGroup } from "#fune/react.form.radio-group@^1";
<RadioGroup id="changedName" legend="Have you changed your name?" inline
hint="This includes changing your last name or spelling your name differently"
error={errors.changedName} value={changed} onChange={setChanged}
options={[{ value: "yes", label: "Yes" }, { value: "no", label: "No" }]} />
For example
-
a legend and three radios: the first takes the id, the rest -2 and -3
<RadioGroup id="contact" legend="How would you prefer to be contacted?" options={[{"value":"email","label":"Email"},{"value":"phone","label":"Phone"},{"value":"text","label":"Text message"}]} />renders
<div class="fune-field"><fieldset class="fune-fieldset"><legend class="fune-legend">How would you prefer to be contacted?</legend><div class="fune-radios"><div class="fune-radio"><input class="fune-radio-input" id="contact" type="radio" name="contact" value="email"/><label class="fune-label fune-radio-label" for="contact">Email</label></div><div class="fune-radio"><input class="fune-radio-input" id="contact-2" type="radio" name="contact" value="phone"/><label class="fune-label fune-radio-label" for="contact-2">Phone</label></div><div class="fune-radio"><input class="fune-radio-input" id="contact-3" type="radio" name="contact" value="text"/><label class="fune-label fune-radio-label" for="contact-3">Text message</label></div></div></fieldset></div> -
inline yes and no, submitted under a name of its own
<RadioGroup id="changed" name="changedName" legend="Have you changed your name?" inline options={[{"value":"yes","label":"Yes"},{"value":"no","label":"No"}]} />renders
<div class="fune-field"><fieldset class="fune-fieldset"><legend class="fune-legend">Have you changed your name?</legend><div class="fune-radios fune-radios--inline"><div class="fune-radio"><input class="fune-radio-input" id="changed" type="radio" name="changedName" value="yes"/><label class="fune-label fune-radio-label" for="changed">Yes</label></div><div class="fune-radio"><input class="fune-radio-input" id="changed-2" type="radio" name="changedName" value="no"/><label class="fune-label fune-radio-label" for="changed-2">No</label></div></div></fieldset></div>
The function
Written for React ^19, in TypeScript only. A component: it takes its props and renders HTML, which the tests below pin exactly.
export function RadioGroup(props: RadioGroupProps): JSX.Element
Its props, RadioGroupProps. A ? marks one the caller may leave out.
| legend | ReactNode | the question the group asks |
| options | readonly RadioOption[] | the radios, in order |
| id? | string | the first radio's id; the others are <id>-2, <id>-3 ...; React's useId() when left out |
| name? | string | what the choice is submitted as; the id when left out |
| value? | string | the chosen value, for a controlled group (with onChange) |
| defaultValue? | string | the value chosen at first, for an uncontrolled one |
| onChange? | (value: string) => void | called with the value of the radio chosen |
| onBlur? | () => void | called when any radio in the group loses focus, to mark the field touched |
| hint? | ReactNode | help under the legend |
| error? | ReactNode | what is wrong with the answer; describes the whole group |
| isPageHeading? | boolean | the legend is the page's heading |
| inline? | boolean | side by side, only for two short options such as Yes and No |
| small? | boolean | smaller radios, for a filter or a dense page |
| required? | boolean | the browser refuses the form until one is chosen |
| disabled? | boolean | every radio is shown but not choosable |
| className? | string | added to the wrapper's classes |
| renders | JSX.Element |
The types it declares, generated into your project
/** One radio in a group. */
export interface RadioOption {
/** what is submitted when it is chosen */
readonly value: string;
/** shown to the right of the radio */
readonly label: ReactNode;
/** help under the label, describing this radio only */
readonly hint?: ReactNode;
/** shown but not choosable */
readonly disabled?: boolean;
/** shown between this option and the one before, such as "or" */
readonly divider?: ReactNode;
}
/** A question with one answer, chosen from a few options. */
export interface RadioGroupProps {
/** the question the group asks */
readonly legend: ReactNode;
/** the radios, in order */
readonly options: readonly RadioOption[];
/** the first radio's id; the others are <id>-2, <id>-3 ...; React's useId() when left out */
readonly id?: string;
/** what the choice is submitted as; the id when left out */
readonly name?: string;
/** the chosen value, for a controlled group (with onChange) */
readonly value?: string;
/** the value chosen at first, for an uncontrolled one */
readonly defaultValue?: string;
/** called with the value of the radio chosen */
readonly onChange?: (value: string) => void;
/** called when any radio in the group loses focus, to mark the field touched */
readonly onBlur?: () => void;
/** help under the legend */
readonly hint?: ReactNode;
/** what is wrong with the answer; describes the whole group */
readonly error?: ReactNode;
/** the legend is the page's heading */
readonly isPageHeading?: boolean;
/** side by side, only for two short options such as Yes and No */
readonly inline?: boolean;
/** smaller radios, for a filter or a dense page */
readonly small?: boolean;
/** the browser refuses the form until one is chosen */
readonly required?: boolean;
/** every radio is shown but not choosable */
readonly disabled?: boolean;
/** added to the wrapper's classes */
readonly className?: string;
}
Your code names it in one line, in the file that uses it
import { RadioGroup } from "#fune/react.form.radio-group@^1";
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
"use client";
import { Fragment, useId, type JSX, type ReactNode } from "react";
import { Fieldset } from "./react_form_fieldset.ts"; ← from react.form.fieldset ^1.0.0 · built alongside by fune
import type { RadioGroupProps } from "./react_form_radio_group_types.ts";
function present(node: ReactNode): boolean {
return node !== null && node !== undefined && node !== false && node !== "";
}
/**
* GOV.UK radios: a fieldset whose legend asks the question, its hint and
* error describing the whole group, then one radio per option, all under one
* name. The first radio takes the id, so an error summary's link lands on
* it, and the rest are <id>-2, <id>-3...; the group's hint and error are
* <id>-hint and <id>-error. A radio has no state of its own to keep: the browser
* knows which is chosen, so value makes it controlled and defaultValue does
* not.
*/
export function RadioGroup(props: RadioGroupProps): JSX.Element {
const auto = useId();
const { legend, options, value, defaultValue, onChange, onBlur, hint, error, isPageHeading, inline, small, required, disabled, className } = props;
const id = props.id ?? auto;
const name = props.name ?? id;
if (options.length === 0) throw new Error("a radio group needs at least one option");
const seen = new Set<string>();
for (const o of options) {
if (seen.has(o.value)) throw new Error(`radio option values must be unique: "${o.value}" is there twice`);
seen.add(o.value);
}
const classes = ["fune-radios", inline ? "fune-radios--inline" : null, small ? "fune-radios--small" : null].filter(Boolean).join(" ");
return (
<Fieldset id={id} legend={legend} hint={hint} error={error} isPageHeading={isPageHeading} className={className}>
<div className={classes}>
{options.map((o, i) => {
const itemId = i === 0 ? id : `${id}-${i + 1}`;
const hintId = present(o.hint) ? `${itemId}-item-hint` : undefined;
return (
<Fragment key={o.value}>
{present(o.divider) ? <div className="fune-radios-divider">{o.divider}</div> : null}
<div className="fune-radio">
<input
className="fune-radio-input"
id={itemId}
name={name}
type="radio"
value={o.value}
checked={value === undefined ? undefined : value === o.value}
defaultChecked={value === undefined && defaultValue !== undefined ? defaultValue === o.value : undefined}
required={required}
disabled={disabled || o.disabled}
aria-describedby={hintId}
onBlur={onBlur ? () => onBlur() : undefined}
onChange={(event) => {
if (event.target.checked) onChange?.(o.value);
}}
/>
<label className="fune-label fune-radio-label" htmlFor={itemId}>
{o.label}
</label>
{hintId ? (
<div className="fune-hint fune-radio-hint" id={hintId}>
{o.hint}
</div>
) : null}
</div>
</Fragment>
);
})}
</div>
</Fieldset>
);
}Install
npm install react react-dom
fune build
With that line in your source, in a TypeScript project (language typescript in fune.project) with React ^19 installed (Functional Weave does not ship it, and fune build stops with the npm install line if it is missing), fune build resolves it and its 1 dependency, pins them in fune.lock, downloads only the TypeScript package of each, and builds the code above into your project’s .fune/build, one readable file per capability with a header linking back here. Or pin a range in fune.project and build in one step:
fune add react.form.radio-group
The manifest, vectors and README with only the TypeScript implementation. Install it without the registry with fune add ./react.form.radio-group-1.0.0-typescript.fune, or fetch it from a terminal with fune pull react.form.radio-group@1.0.0:typescript.
It is React ^19 · TypeScript only, so there is no package for Python or Rust. The full package is one file too: react.form.radio-group-1.0.0.fune, 24,215 bytes, sha256 c03287c5456335c08d7657f819c7b2d40d82abcac4a63036ddd9759cf4d36595.
Customise it in your app
The seams this capability offers. Put a marker directly above a function of your own and fune build wires it into the built code; the package on the registry is not changed, the built file’s header lists it under CUSTOMISED, and fune hooks lists every hook in the project. How hooks work.
before — your function gets the arguments and returns them, changed or not, or throws to refuse the call.
// fune: before react.form.radio-group
after — your function gets the result and the arguments, and returns the final result.
// fune: after react.form.radio-group
replace — inside this capability’s code only, calls to a dependency go to your function, with the same signature. Other capabilities that use it are unaffected; write in * to replace it everywhere.
// fune: replace react.form.fieldset in react.form.radio-group
step — your function runs at a numbered point inside the function’s body, receives the in-scope values it names as parameters, and may return replacements. List the points with fune show react.form.radio-group --steps.
// fune: step react.form.radio-group after <n|label>
Tests
A version published now needs at least 8 tests for every function, and one that expects the error for each function that throws; the registry refuses it otherwise. fune verify --all runs each case in TypeScript alone, with the React in tooling/react, and a project runs them again with fune verify. This page lists the cases; it does not run them. The exact JSON is vectors.json. A component’s tests are render tests: the props go in, and the HTML renderToStaticMarkup makes of them has to match exactly; a React warning during the render fails the test.
-
a legend and three radios: the first takes the id, the rest -2 and -3
<RadioGroup id="contact" legend="How would you prefer to be contacted?" options={[{"value":"email","label":"Email"},{"value":"phone","label":"Phone"},{"value":"text","label":"Text message"}]} />renders
<div class="fune-field"><fieldset class="fune-fieldset"><legend class="fune-legend">How would you prefer to be contacted?</legend><div class="fune-radios"><div class="fune-radio"><input class="fune-radio-input" id="contact" type="radio" name="contact" value="email"/><label class="fune-label fune-radio-label" for="contact">Email</label></div><div class="fune-radio"><input class="fune-radio-input" id="contact-2" type="radio" name="contact" value="phone"/><label class="fune-label fune-radio-label" for="contact-2">Phone</label></div><div class="fune-radio"><input class="fune-radio-input" id="contact-3" type="radio" name="contact" value="text"/><label class="fune-label fune-radio-label" for="contact-3">Text message</label></div></div></fieldset></div> -
inline yes and no, submitted under a name of its own
<RadioGroup id="changed" name="changedName" legend="Have you changed your name?" inline options={[{"value":"yes","label":"Yes"},{"value":"no","label":"No"}]} />renders
<div class="fune-field"><fieldset class="fune-fieldset"><legend class="fune-legend">Have you changed your name?</legend><div class="fune-radios fune-radios--inline"><div class="fune-radio"><input class="fune-radio-input" id="changed" type="radio" name="changedName" value="yes"/><label class="fune-label fune-radio-label" for="changed">Yes</label></div><div class="fune-radio"><input class="fune-radio-input" id="changed-2" type="radio" name="changedName" value="no"/><label class="fune-label fune-radio-label" for="changed-2">No</label></div></div></fieldset></div> -
a controlled value checks only its radio
<RadioGroup id="yn" legend="Continue?" value="no" options={[{"value":"yes","label":"Yes"},{"value":"no","label":"No"}]} />renders
<div class="fune-field"><fieldset class="fune-fieldset"><legend class="fune-legend">Continue?</legend><div class="fune-radios"><div class="fune-radio"><input class="fune-radio-input" id="yn" type="radio" name="yn" value="yes"/><label class="fune-label fune-radio-label" for="yn">Yes</label></div><div class="fune-radio"><input class="fune-radio-input" id="yn-2" type="radio" name="yn" checked="" value="no"/><label class="fune-label fune-radio-label" for="yn-2">No</label></div></div></fieldset></div> -
a default value checks its radio too
<RadioGroup id="yn" legend="Continue?" defaultValue="yes" options={[{"value":"yes","label":"Yes"},{"value":"no","label":"No"}]} />renders
<div class="fune-field"><fieldset class="fune-fieldset"><legend class="fune-legend">Continue?</legend><div class="fune-radios"><div class="fune-radio"><input class="fune-radio-input" id="yn" type="radio" name="yn" checked="" value="yes"/><label class="fune-label fune-radio-label" for="yn">Yes</label></div><div class="fune-radio"><input class="fune-radio-input" id="yn-2" type="radio" name="yn" value="no"/><label class="fune-label fune-radio-label" for="yn-2">No</label></div></div></fieldset></div> -
a controlled empty value checks nothing, and wins over a default
<RadioGroup id="yn" legend="Continue?" value="" defaultValue="yes" options={[{"value":"yes","label":"Yes"},{"value":"no","label":"No"}]} />renders
<div class="fune-field"><fieldset class="fune-fieldset"><legend class="fune-legend">Continue?</legend><div class="fune-radios"><div class="fune-radio"><input class="fune-radio-input" id="yn" type="radio" name="yn" value="yes"/><label class="fune-label fune-radio-label" for="yn">Yes</label></div><div class="fune-radio"><input class="fune-radio-input" id="yn-2" type="radio" name="yn" value="no"/><label class="fune-label fune-radio-label" for="yn-2">No</label></div></div></fieldset></div> -
an item hint describes its own radio, as <item id>-item-hint
<RadioGroup id="signIn" legend="How do you want to sign in?" options={[{"value":"gateway","label":"Sign in with Government Gateway","hint":"You'll have a user ID if you've registered for Self Assessment"},{"value":"verify","label":"Sign in with GOV.UK Verify","hint":"You'll have an account if you've already proved your identity"}]} />renders
<div class="fune-field"><fieldset class="fune-fieldset"><legend class="fune-legend">How do you want to sign in?</legend><div class="fune-radios"><div class="fune-radio"><input class="fune-radio-input" id="signIn" type="radio" aria-describedby="signIn-item-hint" name="signIn" value="gateway"/><label class="fune-label fune-radio-label" for="signIn">Sign in with Government Gateway</label><div class="fune-hint fune-radio-hint" id="signIn-item-hint">You'll have a user ID if you've registered for Self Assessment</div></div><div class="fune-radio"><input class="fune-radio-input" id="signIn-2" type="radio" aria-describedby="signIn-2-item-hint" name="signIn" value="verify"/><label class="fune-label fune-radio-label" for="signIn-2">Sign in with GOV.UK Verify</label><div class="fune-hint fune-radio-hint" id="signIn-2-item-hint">You'll have an account if you've already proved your identity</div></div></div></fieldset></div> -
an or divider before the last option
<RadioGroup id="where" legend="Where do you live?" options={[{"value":"england","label":"England"},{"value":"wales","label":"Wales"},{"value":"abroad","label":"I am a British citizen living abroad","divider":"or"}]} />renders
<div class="fune-field"><fieldset class="fune-fieldset"><legend class="fune-legend">Where do you live?</legend><div class="fune-radios"><div class="fune-radio"><input class="fune-radio-input" id="where" type="radio" name="where" value="england"/><label class="fune-label fune-radio-label" for="where">England</label></div><div class="fune-radio"><input class="fune-radio-input" id="where-2" type="radio" name="where" value="wales"/><label class="fune-label fune-radio-label" for="where-2">Wales</label></div><div class="fune-radios-divider">or</div><div class="fune-radio"><input class="fune-radio-input" id="where-3" type="radio" name="where" value="abroad"/><label class="fune-label fune-radio-label" for="where-3">I am a British citizen living abroad</label></div></div></fieldset></div> -
hint and error describe the fieldset, hint first, and the legend is the page heading
<RadioGroup id="where" legend="Where do you live?" isPageHeading hint="Your main home" error="Select the country where you live" options={[{"value":"england","label":"England"}]} />renders
<div class="fune-field fune-field--error"><fieldset class="fune-fieldset" aria-describedby="where-hint where-error"><legend class="fune-legend fune-legend--heading"><h1 class="fune-legend-heading">Where do you live?</h1></legend><div class="fune-hint" id="where-hint">Your main home</div><p class="fune-error-message" id="where-error"><span class="fune-visually-hidden">Error:</span> Select the country where you live</p><div class="fune-radios"><div class="fune-radio"><input class="fune-radio-input" id="where" type="radio" name="where" value="england"/><label class="fune-label fune-radio-label" for="where">England</label></div></div></fieldset></div> -
small, required, one option disabled
<RadioGroup id="freq" legend="How often?" small required options={[{"value":"month","label":"Monthly"},{"value":"year","label":"Yearly","disabled":true}]} />renders
<div class="fune-field"><fieldset class="fune-fieldset"><legend class="fune-legend">How often?</legend><div class="fune-radios fune-radios--small"><div class="fune-radio"><input class="fune-radio-input" id="freq" type="radio" required="" name="freq" value="month"/><label class="fune-label fune-radio-label" for="freq">Monthly</label></div><div class="fune-radio"><input class="fune-radio-input" id="freq-2" type="radio" required="" disabled="" name="freq" value="year"/><label class="fune-label fune-radio-label" for="freq-2">Yearly</label></div></div></fieldset></div> -
inline and small together; disabled disables every radio; a class name joins the wrapper's
<RadioGroup id="ok" legend="OK?" inline small disabled className="wide" options={[{"value":"yes","label":"Yes"},{"value":"no","label":"No"}]} />renders
<div class="fune-field wide"><fieldset class="fune-fieldset"><legend class="fune-legend">OK?</legend><div class="fune-radios fune-radios--inline fune-radios--small"><div class="fune-radio"><input class="fune-radio-input" id="ok" type="radio" disabled="" name="ok" value="yes"/><label class="fune-label fune-radio-label" for="ok">Yes</label></div><div class="fune-radio"><input class="fune-radio-input" id="ok-2" type="radio" disabled="" name="ok" value="no"/><label class="fune-label fune-radio-label" for="ok-2">No</label></div></div></fieldset></div>
Show the other 4 tests
-
labels and values are escaped
<RadioGroup id="e" legend="Q & A" options={[{"value":"a&b","label":"<A> & \"B\""}]} />renders
<div class="fune-field"><fieldset class="fune-fieldset"><legend class="fune-legend">Q & A</legend><div class="fune-radios"><div class="fune-radio"><input class="fune-radio-input" id="e" type="radio" name="e" value="a&b"/><label class="fune-label fune-radio-label" for="e"><A> & "B"</label></div></div></fieldset></div> -
no options is refused
<RadioGroup id="x" legend="Pick" options={[]} />error: a radio group needs at least one option
-
two options with one value are refused
<RadioGroup id="x" legend="Pick" options={[{"value":"yes","label":"Yes"},{"value":"yes","label":"Yes please"}]} />error: radio option values must be unique: "yes" is there twice
-
an id with a space is refused
<RadioGroup id="where live" legend="Where?" options={[{"value":"a","label":"A"}]} />error: a field id cannot contain spaces
More from the author
- **onChange** is called with the chosen radio's value, not with the event. **value** with onChange is controlled (`""` or any value that is no option checks nothing); **defaultValue** alone is uncontrolled. A radio group needs no state of its own: the browser knows which radio is chosen. - **onBlur** is called, with nothing, when any radio in the group loses focus, so a form can mark the question touched (`form.state`'s blur action). - **Ids.** The first radio's id is the `id`, the rest `<id>-2`, `<id>-3`... as GOV.UK does, so an error summary linking to `#<id>` lands on the first radio. Every radio posts under **name** (the id when left out). An item hint is `<item id>-item-hint` and describes only its radio. - **The group's hint and error** are `<id>-hint` and `<id>-error`, as on GOV.UK; the `<fieldset>` itself has no id (`react.form.fieldset` uses the id only to name them), so the first radio can have it. - **The error is the group's.** As on GOV.UK, the hint and error message describe the fieldset, and no radio is marked `aria-invalid` (ARIA does not support it on a radio; the answer as a whole is wrong). - **inline** (`fune-radios--inline`) sets the radios side by side. GOV.UK: only use it when the question has two options and both are short, like Yes and No. **small** gives `fune-radios--small`. - **divider** on an option shows a `fune-radios-divider` before it, usually "or", to set apart a last option such as "I am a British citizen living abroad" or "None of the above". - **required** puts `required` on every radio, so the browser refuses the form until one is chosen. **disabled** disables every radio; an option's own `disabled` disables it. - Option values must be unique (they are the React keys), and a group needs at least one option.
GOV.UK prefers radios to a select whenever there are few enough options to show at once; see `react.form.select`. It is a client component (it listens for changes), so the built file starts with `"use client"`.
Classes: those of `react.form.fieldset`, plus `fune-radios` (and `fune-radios--inline`, `fune-radios--small`), `fune-radio` (one radio and its label), `fune-radio-input`, `fune-label fune-radio-label`, `fune-hint fune-radio-hint`, `fune-radios-divider`.
Sources: GOV.UK Design System, "Radios" (item ids, item hints, inline radios and when to use them, the divider, small radios, errors on the fieldset) https://design-system.service.gov.uk/components/radios/ and "Fieldset" https://design-system.service.gov.uk/components/fieldset/.
Files
| Path | Bytes |
|---|---|
| README.md | 3,279 |
| impl/typescript.tsx | 3,236 |
| vectors.json | 10,820 |