Functional Weave
Code in TypeScript

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

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

legendReactNodethe question the group asks
optionsreadonly RadioOption[]the radios, in order
id?stringthe first radio's id; the others are <id>-2, <id>-3 ...; React's useId() when left out
name?stringwhat the choice is submitted as; the id when left out
value?stringthe chosen value, for a controlled group (with onChange)
defaultValue?stringthe value chosen at first, for an uncontrolled one
onChange?(value: string) => voidcalled with the value of the radio chosen
onBlur?() => voidcalled when any radio in the group loses focus, to mark the field touched
hint?ReactNodehelp under the legend
error?ReactNodewhat is wrong with the answer; describes the whole group
isPageHeading?booleanthe legend is the page's heading
inline?booleanside by side, only for two short options such as Yes and No
small?booleansmaller radios, for a filter or a dense page
required?booleanthe browser refuses the form until one is chosen
disabled?booleanevery radio is shown but not choosable
className?stringadded to the wrapper's classes
rendersJSX.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";
impl/typescript.tsx · 73 lines · open · raw

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
Download for TypeScript react.form.radio-group-1.0.0-typescript.fune · 24,243 bytes sha256 4ee608f091fd45cd9c15c34e92052cab8973ac440989b1e38ee1b1b804c6234a

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.

  1. 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>
  2. 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>
  3. 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>
  4. 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>
  5. 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>
  6. 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&#x27;ll have a user ID if you&#x27;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&#x27;ll have an account if you&#x27;ve already proved your identity</div></div></div></fieldset></div>
  7. 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>
  8. 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>
  9. 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>
  10. 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
  1. 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 &amp; A</legend><div class="fune-radios"><div class="fune-radio"><input class="fune-radio-input" id="e" type="radio" name="e" value="a&amp;b"/><label class="fune-label fune-radio-label" for="e">&lt;A&gt; &amp; &quot;B&quot;</label></div></div></fieldset></div>
  2. no options is refused

    <RadioGroup id="x" legend="Pick" options={[]} />

    error: a radio group needs at least one option

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

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

PathBytes
README.md3,279
impl/typescript.tsx3,236
vectors.json10,820