A choice of one from a short list, with each option a full-pill row and the chosen one filled ink.

Basic

Pass a label, and a hint if the label cannot say it all. Each Radio can have its own hint under its row. Choose another option to see the Changed chip.

Example

Newsletter frequencyChanged
Every Tuesday morning.
The first Tuesday of the month.
You can change this at any time. Choose another option to see the chip.
Follows the page theme and corners
'use client';

import { Radio, RadioGroup } from '@snapshot-ui/react';
import { useState, type ReactElement } from 'react';

const SAVED = 'weekly';

export default function RadioGroupBasic(): ReactElement {
  const [value, setValue] = useState<string | null>(SAVED);
  return (
    <RadioGroup
      label="Newsletter frequency"
      description="You can change this at any time. Choose another option to see the chip."
      name="frequency"
      value={value}
      onChange={setValue}
      isChanged={value !== SAVED}
      style={{ width: '100%', maxWidth: '24rem' }}
    >
      <Radio value="weekly" description="Every Tuesday morning.">
        Weekly
      </Radio>
      <Radio value="monthly" description="The first Tuesday of the month.">
        Monthly
      </Radio>
      <Radio value="never">Never</Radio>
    </RadioGroup>
  );
}

Horizontal

Set orientation="horizontal" for a short row of options. Options keep their width and wrap onto the next line when the row runs out of room.

Example

Text sizeChanged
Follows the page theme and corners
'use client';

import { Radio, RadioGroup } from '@snapshot-ui/react';
import type { ReactElement } from 'react';

export default function RadioGroupHorizontal(): ReactElement {
  return (
    <RadioGroup label="Text size" name="text-size" orientation="horizontal" defaultValue="medium">
      <Radio value="small">Small</Radio>
      <Radio value="medium">Medium</Radio>
      <Radio value="large">Large</Radio>
    </RadioGroup>
  );
}

States

  • Changed: a clay chip beside the label is the only cue, since a choice has no control edge to recolor. A group with no visible label shows no chip.
  • Invalid: every row gets the error edge, and an error line shows. Invalid outranks changed, so the chip hides.
  • Disabled: the group is recessed and keeps the check on its choice. One option can be disabled on its own, with its label in taupe.

Example

ChangedChanged
Invalid
Choose a frequency.
Disabled groupChanged
Disabled optionChanged
Follows the page theme and corners
'use client';

import { Radio, RadioGroup } from '@snapshot-ui/react';
import type { ReactElement } from 'react';

export default function RadioGroupStates(): ReactElement {
  return (
    <div className="stack" style={{ width: '100%', maxWidth: '24rem' }}>
      <RadioGroup label="Changed" defaultValue="monthly" isChanged>
        <Radio value="weekly">Weekly</Radio>
        <Radio value="monthly">Monthly</Radio>
      </RadioGroup>
      <RadioGroup label="Invalid" isInvalid errorMessage="Choose a frequency.">
        <Radio value="weekly">Weekly</Radio>
        <Radio value="monthly">Monthly</Radio>
      </RadioGroup>
      <RadioGroup label="Disabled group" defaultValue="weekly" isDisabled>
        <Radio value="weekly">Weekly</Radio>
        <Radio value="monthly">Monthly</Radio>
      </RadioGroup>
      <RadioGroup label="Disabled option" defaultValue="free">
        <Radio value="free">Free</Radio>
        <Radio value="team" isDisabled>
          Team
        </Radio>
      </RadioGroup>
    </div>
  );
}

Validation

Set isRequired or a validate function, and the browser's checks run on submit. The error line shows the message, and choosing an option clears it. The first failed submit moves focus to the group.

Example

PlanChanged

Follows the page theme and corners
'use client';

import { Button, Radio, RadioGroup, Text } from '@snapshot-ui/react';
import { useState, type ReactElement } from 'react';

export default function RadioGroupValidation(): ReactElement {
  const [chosen, setChosen] = useState('');
  return (
    <form
      className="stack"
      style={{ width: '100%', maxWidth: '24rem' }}
      onSubmit={(event) => {
        event.preventDefault();
        const plan = new FormData(event.currentTarget).get('plan');
        setChosen(typeof plan === 'string' ? plan : '');
      }}
    >
      <RadioGroup label="Plan" name="plan" isRequired errorMessage="Choose a plan to continue.">
        <Radio value="free">Free</Radio>
        <Radio value="team">Team</Radio>
        <Radio value="business">Business</Radio>
      </RadioGroup>
      <div className="row">
        <Button type="submit">Continue</Button>
      </div>
      <Text tone="muted" role="status">
        {chosen ? `You chose the ${chosen} plan.` : ''}
      </Text>
    </form>
  );
}

Usage

Every value works controlled and uncontrolled. A group with a name submits the chosen value in a plain form. onChange receives the value as a string.

import { useState } from 'react';
import { Radio, RadioGroup } from '@snapshot-ui/react';

export function Frequency({ saved }: { saved: string }) {
  const [value, setValue] = useState<string | null>(saved);
  return (
    <RadioGroup
      label="Newsletter frequency"
      name="frequency"
      value={value}
      onChange={setValue}
      isChanged={value !== saved}
    >
      <Radio value="weekly" description="Every Tuesday morning.">
        Weekly
      </Radio>
      <Radio value="monthly">Monthly</Radio>
      <Radio value="never">Never</Radio>
    </RadioGroup>
  );
}

The types require a name. A group takes a label, an aria-label or an aria-labelledby. A Radio takes its text, an aria-label or an aria-labelledby.

For an on or off setting, use a switch. For a choice that changes the view in place, such as a mode, use a segmented control.

Props

Every React Aria RadioGroup prop is accepted. These are the ones Snapshot UI adds or shapes.

changedLabel
string

The text of the Changed chip.

Default: 'Changed'

defaultValue
string | null

The default value (uncontrolled).

description
ReactNode

A hint under the options. It becomes the group's accessible description.

errorMessage
((validation: ValidationResult) => ReactNode) | ReactNode

The error line, shown only while the group is invalid. A function receives the validation result. Without it the group shows its own validation messages.

isChanged
boolean

Whether the choice differs from the saved one. Shows the clay Changed chip in the label row, which a screen reader reads with the group. An invalid group shows its error instead. Without a visible label no chip shows, but data-changed is still set.

isDisabled
boolean

Whether the input is disabled.

isInvalid
boolean

Whether the input value is invalid.

isReadOnly
boolean

Whether the input can be selected but not changed by the user.

isRequired
boolean

Whether user input is required on the input before form submission.

label
ReactNode

The group's name, shown above the options. Required unless the group has an aria-label or an aria-labelledby.

name
string

The name of the input element, used when submitting an HTML form. See [MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#htmlattrdefname).

onChange
((value: string) => void)

Handler that is called when the value changes.

orientation
Orientation

The axis the Radio Button(s) should align with.

Default: 'vertical'

validate
((value: string) => ValidationError | true | null | undefined)

A function that returns an error message if a given value is invalid. Validation errors are displayed to the user when the form is submitted if validationBehavior="native". For realtime validation, use the isInvalid prop instead.

value
string | null

The current value (controlled).

Each option is a Radio.

children
ReactNode

The option's label. Long text, even an unbroken word, wraps inside the row. Required unless the radio has an aria-label or an aria-labelledby.

description
ReactNode

A hint under the option's row. It becomes the radio's accessible description.

inputRef
Ref<HTMLInputElement>

A ref to the native radio input.

isDisabled
boolean

Whether the radio button is disabled or not. Shows that a selection exists, but is not available in that circumstance.

value
string

The value of the radio button, used when submitting an HTML form. See [MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/radio#Value).

Accessibility

A group of native radio inputs, named by the label. The hint, the error line and, while it shows, the Changed chip describe it. The group is one tab stop, and the arrow keys move the choice. The dot and the check are decoration, so an option's name is its label text.

TabMoves focus into the group, to the chosen option or the first one. The 2px ring sits outside an unchosen row and inside the ink of the chosen one.
DownRightMoves to the next option and chooses it.
UpLeftMoves to the previous option and chooses it.
SpaceChooses the focused option.
  • The arrow keys skip disabled options.
  • A read-only group keeps its choice and has no hover wash.
  • Do not disable the chosen option of a group the reader can still change. Only the chosen radio gets the tab stop, and a disabled one loses it, so Tab skips the group.

A group or option with no name does not compile. TypeScript's message names only aria-labelledby or says the props are not assignable. Either one means a missing accessible name.

Design rules

What keeps radio groups in the system.

Name the group in the label, in sentence case, and keep options short enough to scan.

Choose a default when one is safe. A required group with nothing chosen shows the error only after a failed submit.

Show the chip while the choice is unsaved, and only then.

Never use a radio group for more than about six options. Use a select.

Never leave the chip beside an error. Invalid outranks changed.

Never hide the check. Color is not the only sign of the chosen option.

esc

↑ ↓ to move↵ to open