FairGarden Design

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?

RadioStates.tsx
'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

RadioKinds.tsx
'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.

RadioColor.tsx
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.

paper
forest
RadioGrounds.tsx
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].

PropTypeDescription
description
React.ReactNode | undefined

Optional helper text under the label, in sentence case.

feedback
React.ReactNode | undefined

Quiz feedback shown after the label: a RadioFeedback.

kind
'standard' | 'pill' | 'swatch' | null | undefined

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.

primary
| 'ruby'
| 'olive'
| 'sage'
| 'slate'
| 'sand'
| 'gray'
| 'mauve'
| 'brown'
| 'bronze'
| 'gold'
| 'red'
| 'crimson'
| 'tomato'
| 'pink'
| 'plum'
| 'indigo'
| 'iris'
| 'violet'
| 'purple'
| null
| undefined

Primary Radix scale: circle edge, labels, swatch rings and focus ring. Never defaulted; omitted, it inherits the scope [D133].

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
| undefined

Secondary Radix scale: the checked fill, dot and edge through --role-select. Omitted, it inherits the scope. Never the danger scale.

swatch
string | undefined

swatch only: the disc’s color or image, as a CSS background value. The color is content, not a role; its name is children.

children
React.ReactNode | undefined

The label, authored in title case up to about four words [D160]; a swatch’s color name.

className
string | undefined

Class for the row (the base part).

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.

PropTypeDescription
kind
'standard' | 'pill' | 'swatch' | null | undefined

The build of the radios inside, and so the layout: standard (default), pill (option pills, always stacked) or swatch (color discs that wrap). Each Radio takes it as its default kind.

primary
| 'ruby'
| 'olive'
| 'sage'
| 'slate'
| 'sand'
| 'gray'
| 'mauve'
| 'brown'
| 'bronze'
| 'gold'
| 'red'
| 'crimson'
| 'tomato'
| 'pink'
| 'plum'
| 'indigo'
| 'iris'
| 'violet'
| 'purple'
| null
| undefined

Primary Radix scale for every radio inside. Never defaulted [D133].

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
| undefined

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].

PropTypeDescription
primary
| 'ruby'
| 'olive'
| 'sage'
| 'slate'
| 'sand'
| 'gray'
| 'mauve'
| 'brown'
| 'bronze'
| 'gold'
| 'red'
| 'crimson'
| 'tomato'
| 'pink'
| 'plum'
| 'indigo'
| 'iris'
| 'violet'
| 'purple'
| null
| undefined

Primary Radix scale: the word. Never defaulted [D133].

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
| undefined

Secondary Radix scale: the glyph. Omitted, the status scale (green or red).

status
RadioFeedbackStatus | undefined

success for the correct answer, danger for a wrong pick.

children
React.ReactNode | undefined

The word; defaults to “Correct” or “Not quite”.

Additional types

radio
type radio = radio
radioFeedback
RadioFeedbackProps

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
RadioGroupProps

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).