macbetter

Buttons and controls

Radio

Choose one option from a small set of mutually exclusive choices.

Appearance
import { Radio, RadioGroup } from "macbetter/radio"

export default function Example() {
  return (
    <div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
      <span id="appearance" style={{ font: "var(--mb-text-headline)" }}>
        Appearance
      </span>
      <RadioGroup aria-labelledby="appearance" defaultValue="auto">
        <Radio value="light">Light</Radio>
        <Radio value="dark">Dark</Radio>
        <Radio value="auto">Automatic</Radio>
      </RadioGroup>
    </div>
  )
}

When to use it

Use radio buttons for two to about five choices where exactly one applies and people benefit from seeing them all at once, like a backup schedule. For more choices, use a Pop-Up Button. For a choice that switches what's on screen, use a Segmented Control. For a single on/off option, use a Checkbox.

  • Start with a sensible default checked, so the group always has a value.
  • Keep titles short and parallel. Put details in a description.
  • On iOS, the familiar form is a list of rows with a checkmark on the chosen one. Use variant="list".

Usage

import { Radio, RadioGroup } from "macbetter/radio"

Give each Radio a value and its title as children. Name the group with aria-label, aria-labelledby or a Field label.

Description

import { Radio, RadioGroup } from "macbetter/radio"

export default function Example() {
  return (
    <RadioGroup aria-label="Backup frequency" defaultValue="daily" style={{ maxWidth: 360 }}>
      <Radio value="hourly" description="Keeps the most recent 24 hours of changes.">
        Every hour
      </Radio>
      <Radio value="daily" description="Runs once a day while your Mac is idle.">
        Every day
      </Radio>
      <Radio value="weekly" description="Runs on Sunday night.">
        Every week
      </Radio>
    </RadioGroup>
  )
}

List

variant="list" draws each choice as a row in an inset group, with a checkmark at the trailing edge of the chosen one, as in iOS Settings.

import { Radio, RadioGroup } from "macbetter/radio"

export default function Example() {
  return (
    <RadioGroup variant="list" aria-label="Auto-Lock" defaultValue="60" style={{ width: "100%", maxWidth: 360 }}>
      <Radio value="30">30 seconds</Radio>
      <Radio value="60">1 minute</Radio>
      <Radio value="120">2 minutes</Radio>
      <Radio value="300">5 minutes</Radio>
      <Radio value="never">Never</Radio>
    </RadioGroup>
  )
}

Disabled

Disable a single radio to show a choice that isn't available now, or the group to disable all of them.

import { Radio, RadioGroup } from "macbetter/radio"

export default function Example() {
  return (
    <RadioGroup aria-label="Video quality" defaultValue="hd">
      <Radio value="sd">Standard</Radio>
      <Radio value="hd">High Definition</Radio>
      <Radio value="4k" disabled description="Needs a faster connection.">
        4K
      </Radio>
    </RadioGroup>
  )
}

Controlled

$49.99 a year, two months free
"use client"

import { Radio, RadioGroup } from "macbetter/radio"
import { useState } from "react"

const prices = { monthly: "$4.99 a month", yearly: "$49.99 a year, two months free" }

type Plan = keyof typeof prices

export default function Example() {
  const [plan, setPlan] = useState<Plan>("yearly")

  return (
    <div style={{ display: "flex", flexDirection: "column", gap: 14 }}>
      <RadioGroup aria-label="Plan" value={plan} onValueChange={(value) => value && setPlan(value as Plan)}>
        <Radio value="monthly">Monthly</Radio>
        <Radio value="yearly">Yearly</Radio>
      </RadioGroup>
      <span style={{ color: "var(--mb-label-secondary)" }}>{prices[plan]}</span>
    </div>
  )
}

Platform behavior

  • macOS. A 14-point circle with a hairline edge. Checked, it fills with the accent around a dot that springs in: white on the system blue, or the theme's on-accent color, so the dot holds 3:1 on any accent.
  • iPadOS and iOS. A larger, 22-point circle with an outlined ring, and the same accent fill and dot. The list variant uses 48-point rows, inset separators and an accent checkmark.
  • Every platform. The hit area extends to the platform minimum, and the whole label is pressable.

Accessibility

  • The group has the radiogroup role and is one tab stop: Tab enters on the checked radio, and the arrow keys move and check. Space checks the focused radio.
  • Each radio renders a button with the radio role, plus a hidden native radio for forms.
  • In the list variant, the focus ring goes around the whole row.

API

RadioGroup

PropTypeDefaultDescription
variant"default" | "list""default"Circles beside titles, or rows with a trailing checkmark.
valuestring | null—The checked radio's value. Pass it to control the group.
defaultValuestring | null—The radio checked at first.
onValueChange(value, details) => void—Runs when the choice wants to change.
disabledbooleanfalseDisables every radio.
readOnlybooleanfalseFocusable and submitted, but doesn't change.
requiredbooleanfalseBlocks form submission until a radio is checked.
namestring—The name the value submits under.
loopboolean—Whether arrow keys wrap from the last radio to the first.

Radio

PropTypeDefaultDescription
valuestring—What the group's value becomes when this radio is checked.
childrenReactNode—The title. With it, the radio and the text form one label.
descriptionReactNode—Secondary text under the title.
disabledbooleanfalseSkips this radio.

Data attributes

AttributeOnValues
data-variantRadioGroupdefault, list
data-checked / data-uncheckedRadio, indicatorThe state
data-disabled, data-readonly, data-requiredBothPresent in those states
data-invalidRadioGroupPresent while a Field shows the group as invalid; its unchecked radios get a red edge

CSS variables

VariablemacOSiOSWhat it sets
--mb-radio-size14px22pxThe circle
--mb-radio-dot6px9pxThe dot
--mb-radio-label-gap6px12pxBetween the circle and its title
--mb-radio-group-gap6px16pxBetween radios