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
radiogrouprole 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
radiorole, plus a hidden native radio for forms. - In the list variant, the focus ring goes around the whole row.
API
RadioGroup
| Prop | Type | Default | Description |
|---|---|---|---|
variant | "default" | "list" | "default" | Circles beside titles, or rows with a trailing checkmark. |
value | string | null | — | The checked radio's value. Pass it to control the group. |
defaultValue | string | null | — | The radio checked at first. |
onValueChange | (value, details) => void | — | Runs when the choice wants to change. |
disabled | boolean | false | Disables every radio. |
readOnly | boolean | false | Focusable and submitted, but doesn't change. |
required | boolean | false | Blocks form submission until a radio is checked. |
name | string | — | The name the value submits under. |
loop | boolean | — | Whether arrow keys wrap from the last radio to the first. |
Radio
| Prop | Type | Default | Description |
|---|---|---|---|
value | string | — | What the group's value becomes when this radio is checked. |
children | ReactNode | — | The title. With it, the radio and the text form one label. |
description | ReactNode | — | Secondary text under the title. |
disabled | boolean | false | Skips this radio. |
Data attributes
| Attribute | On | Values |
|---|---|---|
data-variant | RadioGroup | default, list |
data-checked / data-unchecked | Radio, indicator | The state |
data-disabled, data-readonly, data-required | Both | Present in those states |
data-invalid | RadioGroup | Present while a Field shows the group as invalid; its unchecked radios get a red edge |
CSS variables
| Variable | macOS | iOS | What it sets |
|---|---|---|---|
--mb-radio-size | 14px | 22px | The circle |
--mb-radio-dot | 6px | 9px | The dot |
--mb-radio-label-gap | 6px | 12px | Between the circle and its title |
--mb-radio-group-gap | 6px | 16px | Between radios |