Radio
A Base UI Radio in its own label row, always inside a Radio Group, for one choice from 2–7 visible options, quiz answers included.
import { Radio, RadioFeedback } from '@fairgarden-private/design/components/Radio'
import { RadioGroup } from '@fairgarden-private/design/components/RadioGroup'
The whole row is the hit target. Checked is a --role-select fill, an 8 px ● in --role-select-mark and a 3 px --role-select-edge. Arrow keys move the selection and Tab leaves the group. With eight or more options use a Select; to switch views use a Toggle Group or Tabs.
States and quiz feedback
States and quiz feedback
Hover gives an unchecked circle a soft --primary3 fill where soft fills apply, lightens a checked circle or pill to --role-select-hover and underlines the label in the accent; edges never change on hover. A disabled radio or group draws dotted edges and muted labels, never dimmed; say "(unavailable)" where the reason matters. feedback takes a RadioFeedback: the success glyph with "Correct" or the danger glyph with "Not quite", always a glyph plus a word.
Delivery
Membership Tier
Which bird nests on the ground?
'use client'
import * as React from 'react'
import { Radio, RadioFeedback } from '@fairgarden-private/design/components/Radio'
import { RadioGroup } from '@fairgarden-private/design/components/RadioGroup'
import styles from './states.module.css'
/**
* A preselected group with a disabled option, a disabled group, and a quiz
* whose answers gain a glyph and a word once checked.
*/
export function RadioStates() {
const deliveryId = React.useId()
const lockedId = React.useId()
const quizId = React.useId()
const [answer, setAnswer] = React.useState<string | undefined>(undefined)
return (
<div className={styles.stack}>
<div className={styles.group}>
<p id={deliveryId} className={styles.legend}>
Delivery
</p>
<RadioGroup aria-labelledby={deliveryId} defaultValue="standard">
<Radio value="standard" description="Arrives in 5–7 days.">
Standard Post
</Radio>
<Radio value="express">Express Post</Radio>
<Radio value="pickup" disabled>
Store Pickup (unavailable)
</Radio>
</RadioGroup>
</div>
<div className={styles.group}>
<p id={lockedId} className={styles.legend}>
Membership Tier
</p>
<RadioGroup aria-labelledby={lockedId} defaultValue="family" disabled>
<Radio value="single">Single</Radio>
<Radio value="family">Family</Radio>
</RadioGroup>
</div>
<div className={styles.group}>
<p id={quizId} className={styles.legend}>
Which bird nests on the ground?
</p>
<RadioGroup
aria-labelledby={quizId}
value={answer}
onValueChange={(value) => setAnswer(value as string)}
>
<Radio
value="killdeer"
feedback={answer ? <RadioFeedback status="success" /> : null}
>
Killdeer
</Radio>
<Radio
value="heron"
feedback={answer === 'heron' ? <RadioFeedback status="danger" /> : null}
>
Great Blue Heron
</Radio>
<Radio
value="swift"
feedback={answer === 'swift' ? <RadioFeedback status="danger" /> : null}
>
Chimney Swift
</Radio>
</RadioGroup>
</div>
</div>
)
}
Option pills and swatches
Option pills and swatches
kind="pill" stacks 48 px option pills; the checked pill fills with --role-select, takes a 2 px edge and gains a leading ●, and hover steps its edge to --primary12. Pills always stack. kind="swatch" shows a 32 px disc whose color is content (swatch); the chosen disc gains a 3 px ring outside a ground gap and a knockout ● that reads on any color. The swatch's name is its accessible label and prints beneath it; let the legend echo the choice ("Color: Moss").
How often do you hike?
Color: Moss
'use client'
import * as React from 'react'
import { Radio } from '@fairgarden-private/design/components/Radio'
import { RadioGroup } from '@fairgarden-private/design/components/RadioGroup'
import styles from './kinds.module.css'
const swatches = [
{ value: 'moss', name: 'Moss', color: 'var(--grass9)' },
{ value: 'rust', name: 'Rust', color: 'var(--orange9)' },
{ value: 'slate', name: 'Slate', color: 'var(--slate11)' },
{ value: 'oat', name: 'Oat', color: 'var(--sand4)' },
{ value: 'ink', name: 'Ink', color: 'var(--blue12)' },
]
/**
* Option pills (stacked answers) and swatches (color discs whose name is
* the label, echoed in the legend).
*/
export function RadioKinds() {
const pillsId = React.useId()
const swatchId = React.useId()
const [color, setColor] = React.useState('moss')
const chosen = swatches.find((swatch) => swatch.value === color)
return (
<div className={styles.stack}>
<div className={styles.group}>
<p id={pillsId} className={styles.legend}>
How often do you hike?
</p>
<RadioGroup kind="pill" aria-labelledby={pillsId} defaultValue="monthly">
<Radio value="weekly">Every Week</Radio>
<Radio value="monthly">Every Month</Radio>
<Radio value="yearly">A Few Times a Year</Radio>
<Radio value="never" disabled>
Not Yet (unavailable)
</Radio>
</RadioGroup>
</div>
<div className={styles.group}>
<p id={swatchId} className={styles.legend}>
Color: {chosen?.name}
</p>
<RadioGroup
kind="swatch"
aria-labelledby={swatchId}
value={color}
onValueChange={(value) => setColor(value as string)}
>
{swatches.map((swatch) => (
<Radio key={swatch.value} value={swatch.value} swatch={swatch.color}>
{swatch.name}
</Radio>
))}
</RadioGroup>
</div>
</div>
)
}
Primary and secondary
Primary and secondary
secondary drives the checked fill, dot and edge; primary the circles, labels, swatch rings and focus ring. Neither is defaulted, and the secondary never becomes the danger scale.
import { Radio } from '@fairgarden-private/design/components/Radio'
import { RadioGroup } from '@fairgarden-private/design/components/RadioGroup'
import styles from './color.module.css'
/**
* `secondary` drives the checked fill, dot and edge; `primary` drives the
* circle edge, labels and focus ring. A group passes its scales down.
*/
export function RadioColor() {
return (
<div className={styles.grid}>
<RadioGroup aria-label="Scope colors" defaultValue="a">
<Radio value="a">Scope Colors</Radio>
<Radio value="b">Unchecked</Radio>
</RadioGroup>
<RadioGroup aria-label="Secondary indigo" secondary="indigo" defaultValue="a">
<Radio value="a">Secondary Indigo</Radio>
<Radio value="b">Unchecked</Radio>
</RadioGroup>
<RadioGroup aria-label="Primary plum" primary="plum" defaultValue="a">
<Radio value="a">Primary Plum</Radio>
<Radio value="b">Unchecked</Radio>
</RadioGroup>
</div>
)
}
On grounds
On paper and forest
On paper the selection is green; on forest and the saturated grounds it is the inverse pair, for option pills too.
import { Radio } from '@fairgarden-private/design/components/Radio'
import { RadioGroup } from '@fairgarden-private/design/components/RadioGroup'
import { PresetGround } from '@/components/PresetGround'
import styles from './grounds.module.css'
const presets = ['paper', 'forest'] as const
/** Green selection on paper; the inverse pair on forest, for circles and pills alike. */
export function RadioGrounds() {
return (
<div className={styles.row}>
{presets.map((preset) => (
<PresetGround key={preset} preset={preset} className={styles.face}>
<span className={styles.name}>{preset}</span>
<RadioGroup aria-label={`Trail length on ${preset}`} defaultValue="short">
<Radio value="short">Short Loop</Radio>
<Radio value="long">Long Loop</Radio>
<Radio value="closed" disabled>
Closed Loop
</Radio>
</RadioGroup>
<RadioGroup kind="pill" aria-label={`Pace on ${preset}`} defaultValue="easy">
<Radio value="easy">Easy Pace</Radio>
<Radio value="brisk">Brisk Pace</Radio>
</RadioGroup>
</PresetGround>
))}
</div>
)
}
API Reference
Radio
A Base UI Radio inside its own label row, so the whole row is the hit target. Always inside a RadioGroup. Checked is a --role-select fill, an 8 px ● in --role-select-mark and a 3 px edge, never fill alone [D15].
| Prop | Type | Description |
|---|---|---|
description | | Optional helper text under the label, in sentence case. |
feedback | | Quiz feedback shown after the label: a RadioFeedback. |
kind | |
|
primary | | Primary Radix scale: circle edge, labels, swatch rings and focus ring. Never defaulted; omitted, it inherits the scope [D133]. |
secondary | | Secondary Radix scale: the checked fill, dot and edge through --role-select. Omitted, it inherits the scope. Never the danger scale. |
swatch | |
|
children | | The label, authored in title case up to about four words [D160]; a swatch’s color name. |
className | | Class for the row (the |
RadioGroup
One choice from 2–7 visible options (Base UI Radio Group). Arrow keys move the selection; Tab leaves the group. Preselect a sensible default unless the choice must be deliberate. Eight or more options are a Select. For swatches, let the legend name the choice (“Color: Forest”). The root is an inline-size container, so give it a width in shrink-to-fit layouts.
| Prop | Type | Description |
|---|---|---|
kind | | The build of the radios inside, and so the layout: |
primary | | Primary Radix scale for every radio inside. Never defaulted [D133]. |
secondary | | Secondary Radix scale for every radio inside. Never defaulted, never the danger scale. |
RadioFeedback
Quiz feedback for a Radio’s feedback slot: the §1.5.4 success glyph
(● with its check) or danger glyph (◆ with its ×) in --role-status,
always paired with a word, so fill is never the only signal [D58].
| Prop | Type | Description |
|---|---|---|
primary | | Primary Radix scale: the word. Never defaulted [D133]. |
secondary | | Secondary Radix scale: the glyph. Omitted, the status scale (green or red). |
status | |
|
children | | The word; defaults to “Correct” or “Not quite”. |
Additional types
radioFeedback
type radioFeedback = radioFeedbackRadioFeedbackProps
Props for RadioFeedback: span props, the status, the word and the color axes.
type RadioFeedbackProps = {
/** `success` for the correct answer, `danger` for a wrong pick. */
status: RadioFeedbackStatus;
/** Primary Radix scale: the word. Never defaulted [D133]. */
primary?:
| 'ruby'
| 'olive'
| 'sage'
| 'slate'
| 'sand'
| 'gray'
| 'mauve'
| 'brown'
| 'bronze'
| 'gold'
| 'red'
| 'crimson'
| 'tomato'
| 'pink'
| 'plum'
| 'indigo'
| 'iris'
| 'violet'
| 'purple'
| null;
/** Secondary Radix scale: the glyph. Omitted, the status scale (green or red). */
secondary?:
| 'ruby'
| 'olive'
| 'sage'
| 'slate'
| 'sand'
| 'gray'
| 'mauve'
| 'brown'
| 'bronze'
| 'gold'
| 'red'
| 'crimson'
| 'tomato'
| 'pink'
| 'plum'
| 'indigo'
| 'iris'
| 'violet'
| 'purple'
| 'amber'
| 'blue'
| 'cyan'
| 'grass'
| 'green'
| 'jade'
| 'lime'
| 'mint'
| 'orange'
| 'sky'
| 'teal'
| 'yellow'
| null;
/** The word; defaults to "Correct" or "Not quite". */
children?: React.ReactNode;
}RadioFeedbackStatus
Quiz feedback status (§10.8).
type RadioFeedbackStatus = 'success' | 'danger'radioGroup
type radioGroup = radioGroupRadioGroupProps
Props for RadioGroup: Base UI RadioGroup props plus the kind and color
axes. Label the group with a Fieldset legend or aria-labelledby.
type RadioGroupProps<Value = unknown> = RadioGroup.Props<Value> & {
kind?: 'standard' | 'pill' | 'swatch' | null;
primary?:
| 'ruby'
| 'olive'
| 'sage'
| 'slate'
| 'sand'
| 'gray'
| 'mauve'
| 'brown'
| 'bronze'
| 'gold'
| 'red'
| 'crimson'
| 'tomato'
| 'pink'
| 'plum'
| 'indigo'
| 'iris'
| 'violet'
| 'purple'
| null;
secondary?:
| 'ruby'
| 'olive'
| 'sage'
| 'slate'
| 'sand'
| 'gray'
| 'mauve'
| 'brown'
| 'bronze'
| 'gold'
| 'red'
| 'crimson'
| 'tomato'
| 'pink'
| 'plum'
| 'indigo'
| 'iris'
| 'violet'
| 'purple'
| 'amber'
| 'blue'
| 'cyan'
| 'grass'
| 'green'
| 'jade'
| 'lime'
| 'mint'
| 'orange'
| 'sky'
| 'teal'
| 'yellow'
| null;
}RadioKind
The structural build of a Radio (§10.8).
type RadioKind = 'standard' | 'pill' | 'swatch'<'standard' | 'pill' | 'swatch' | null | undefined>RadioKindContext
The kind a RadioGroup passes to the radios inside it. A radio’s own kind wins.
type RadioKindContext = React.Context<RadioKind | undefined>RadioProps
Props for Radio: Base UI Radio.Root props (on the circle) plus the kind,
color axes and the row’s content. className goes on the row.
type RadioProps<Value = unknown> = {
/**
* `standard` (default): circle plus label. `pill`: a stacked
* `--size-px-8` option pill. `swatch`: a color disc whose name is the
* accessible label (shown in print). Inside a RadioGroup the group's kind
* is the default.
*/
kind?: 'standard' | 'pill' | 'swatch' | null;
/**
* Primary Radix scale: circle edge, labels, swatch rings and focus ring.
* Never defaulted; omitted, it inherits the scope [D133].
*/
primary?:
| 'ruby'
| 'olive'
| 'sage'
| 'slate'
| 'sand'
| 'gray'
| 'mauve'
| 'brown'
| 'bronze'
| 'gold'
| 'red'
| 'crimson'
| 'tomato'
| 'pink'
| 'plum'
| 'indigo'
| 'iris'
| 'violet'
| 'purple'
| null;
/**
* Secondary Radix scale: the checked fill, dot and edge through
* --role-select. Omitted, it inherits the scope. Never the danger scale.
*/
secondary?:
| 'ruby'
| 'olive'
| 'sage'
| 'slate'
| 'sand'
| 'gray'
| 'mauve'
| 'brown'
| 'bronze'
| 'gold'
| 'red'
| 'crimson'
| 'tomato'
| 'pink'
| 'plum'
| 'indigo'
| 'iris'
| 'violet'
| 'purple'
| 'amber'
| 'blue'
| 'cyan'
| 'grass'
| 'green'
| 'jade'
| 'lime'
| 'mint'
| 'orange'
| 'sky'
| 'teal'
| 'yellow'
| null;
/** Class for the row (the `base` part). */
className?: string;
/** The label, authored in title case up to about four words [D160]; a swatch's color name. */
children?: React.ReactNode;
/** Optional helper text under the label, in sentence case. */
description?: React.ReactNode;
/**
* `swatch` only: the disc's color or image, as a CSS `background` value.
* The color is content, not a role; its name is `children`.
*/
swatch?: string;
/** Quiz feedback shown after the label: a RadioFeedback. */
feedback?: React.ReactNode;
}Specification: DESIGN-SYSTEM.md §10.8 (radio group) and §10.1 (the selected fill).