Skip to content
txbrendan/ui

Radio Group

beta

A set of radios where people pick exactly one option, with built-in labels, tones, sizes, and arrow-key movement.

Preview

tone

size

states

details

Plan

row

field

no text

Install

  1. 1. Add the registry to components.json, once per app. Without it, the shadcn CLI looks up @txbrendan in a public list that txbrendan/ui does not control.

    npx shadcn@latest registry add "@txbrendan=https://txbrendan.dev/r/{name}.json"
  2. 2. Add the theme, once per app. It replaces your color tokens with ones checked for contrast.

    npx shadcn@latest add @txbrendan/theme
  3. 3. Add the component.

    npx shadcn@latest add @txbrendan/radio-group

Use it for

  • Picking exactly one option from a short list, like a delivery speed or a plan.
  • Two to six options that all fit on screen. People compare them at a glance.
  • Pass each option's label as RadioGroupItem children. Clicking the text checks the radio.
  • Name the group with aria-label, or aria-labelledby pointing at a visible heading or legend.
  • Set tone and size once, on RadioGroup. Every item follows them.

Avoid it for

  • Picking any number of options. Use checkboxes.
  • Options with a hint, a price, or a consequence. Use SelectionGroup with a SelectionCard for each.
  • A setting that turns on or off. Use a switch.
  • Long lists, more than about seven options. Use a select or a combobox.
  • Starting with nothing checked when one answer is the safe default. Set defaultValue to it.

Props

  • RadioGroup

    Root. Holds the value. Takes value, defaultValue, onValueChange, name, required, readOnly, and disabled.

    RadioGroupProps

  • aria-label

    RadioGroup. Names the group. TypeScript requires this or aria-labelledby.

    string

  • aria-labelledby

    RadioGroup. The id of visible text that names the group, like a heading or a fieldset legend.

    string

  • tone

    RadioGroup. Color of the checked radio, for every item.

    "neutral" | "brand" | "danger" | "success" | "warning" · default "neutral"

  • size

    RadioGroup. Radio size and label text size, for every item. Density scales them.

    "sm" | "md" | "lg" · default "md"

  • value

    RadioGroup. The checked option, controlled. Use it with onValueChange.

    string

  • defaultValue

    RadioGroup. The option checked at the start when you do not control it.

    string

  • onValueChange

    RadioGroup. Called with the new value when someone picks an option.

    (value: string, details) => void

  • name

    RadioGroup. Form field name. The checked value is sent with the form.

    string

  • disabled

    RadioGroup or RadioGroupItem. Blocks picking. On the group it blocks every item.

    boolean · default false

  • readOnly

    RadioGroup. Shows the value, but no one can change it. Items stay focusable.

    boolean · default false

  • required

    RadioGroup. A form will not send until an option is picked.

    boolean · default false

  • aria-invalid

    RadioGroup. Turns the group to the danger tone. Set it when validation fails, and link the message with aria-describedby.

    boolean

  • RadioGroupItem

    Part. One option. Takes value, which is required, and disabled.

    RadioGroupItemProps

  • children

    RadioGroupItem. Visible label text. Without it, TypeScript requires aria-label, aria-labelledby, or an id that a label points at.

    ReactNode

  • className

    RadioGroup: classes for the group, so className='flex-row flex-wrap' lays items in a row. RadioGroupItem: classes for the circle. Later classes win.

    string

Accessibility

  • The group has role="radiogroup". Each item renders a span with role="radio" plus a hidden native radio, so the value is sent with the form.
  • The group is one tab stop. Tab lands on the checked radio, or on the first one when none is checked.
  • Arrow keys move the choice and check the radio they land on. They skip disabled items. Space checks the focused radio.
  • TypeScript requires a name for the group, and children, aria-label, aria-labelledby, or an id for each item. It cannot check that a label points at the id.
  • In server HTML, only aria-label and aria-labelledby name a radio. A label from children or an id is linked once JavaScript runs.
  • The unchecked border uses --input and the focus ring uses --ring. The theme tests hold both to 3:1 against the page and cards.
  • The dot uses the tone's foreground on its fill, which the theme tests hold to 4.5:1.
  • Press scale and the dot's pop-in follow --motion-* tokens. Reduced motion turns both off and keeps the fade.

Examples

Named by a visible heading

<div className="grid gap-3">
  <p id="delivery-label" className="text-sm font-medium">
    Delivery speed
  </p>
  <RadioGroup aria-labelledby="delivery-label" name="delivery" defaultValue="standard" tone="brand">
    <RadioGroupItem value="standard">Standard, 3 to 5 days</RadioGroupItem>
    <RadioGroupItem value="express">Express, next day</RadioGroupItem>
    <RadioGroupItem value="pickup">Pick up in store</RadioGroupItem>
  </RadioGroup>
</div>

A label with a second line

<RadioGroup aria-label="Plan" defaultValue="team">
  <RadioGroupItem value="solo">
    <span className="block font-medium">Solo</span>
    <span className="block text-muted-foreground">One person, three projects.</span>
  </RadioGroupItem>
  <RadioGroupItem value="team">
    <span className="block font-medium">Team</span>
    <span className="block text-muted-foreground">Up to ten people, every project.</span>
  </RadioGroupItem>
</RadioGroup>

In a row

<RadioGroup aria-label="Size" defaultValue="m" className="flex-row flex-wrap gap-x-6">
  <RadioGroupItem value="s">Small</RadioGroupItem>
  <RadioGroupItem value="m">Medium</RadioGroupItem>
  <RadioGroupItem value="l">Large</RadioGroupItem>
</RadioGroup>

Controlled

"use client";
import { useState } from "react";

function Theme() {
  const [theme, setTheme] = useState("system");
  return (
    <RadioGroup aria-label="Theme" value={theme} onValueChange={(value) => setTheme(value as string)}>
      <RadioGroupItem value="light">Light</RadioGroupItem>
      <RadioGroupItem value="dark">Dark</RadioGroupItem>
      <RadioGroupItem value="system">Match my device</RadioGroupItem>
    </RadioGroup>
  );
}

Required, in a form, with an error

"use client";
import { useState } from "react";

function DeliverySpeed() {
  const [missing, setMissing] = useState(false);
  return (
    <form
      noValidate
      className="grid gap-2"
      onSubmit={(event) => {
        if (!new FormData(event.currentTarget).get("speed")) {
          event.preventDefault();
          setMissing(true);
        }
      }}
    >
      <p id="speed-label" className="text-sm font-medium">
        Delivery speed
      </p>
      <RadioGroup
        name="speed"
        required
        aria-labelledby="speed-label"
        aria-invalid={missing || undefined}
        aria-describedby={missing ? "speed-error" : undefined}
        onValueChange={() => setMissing(false)}
      >
        <RadioGroupItem value="standard">Standard</RadioGroupItem>
        <RadioGroupItem value="express">Express</RadioGroupItem>
      </RadioGroup>
      {missing && (
        <p id="speed-error" className="text-sm text-destructive-soft-foreground">
          Pick a delivery speed.
        </p>
      )}
      <button type="submit">Order</button>
    </form>
  );
}