Radio cards
StableA radio group drawn as cards, for choices that each need a sentence, where the whole card is the target.
Native radios underneath, so arrow keys and the single Tab stop work as in any radio group. Each card holds an optional icon, a label and a description; the chosen card gets a firmer ring and the radio’s square. Cards stack on phones; from 640px wide they sit side by side, at least 10rem each, or in the columns you set.
With icons
import { useState } from 'react'import { RadioCards } from '@rchursin/product/forms'
type Visibility = 'private' | 'team' | 'link'
/** * For choices that need a sentence each. The whole card is the target; the chosen one gets a * firmer ring and the radio's square. Cards stack on phones; from 640px wide they sit side by side. */export default function RadioCardsVisibility() { const [visibility, setVisibility] = useState<Visibility>('team')
return ( <RadioCards<Visibility> legend="Who can see this note" name="visibility" className="w-full" value={visibility} onChange={setVisibility} options={[ { value: 'private', label: 'Only me', description: 'Hidden from search and from your team.', icon: 'lock' }, { value: 'team', label: 'My team', description: 'Everyone in the Platform workspace can read it.', icon: 'users' }, { value: 'link', label: 'Anyone with the link', description: 'Read-only. You can turn the link off later.', icon: 'link' }, ]} /> )}Usage
import { RadioCards, type RadioCardOption } from '@rchursin/product/forms'<RadioCards legend="Who can see this note" value={visibility} onChange={setVisibility} options={[ { value: 'private', label: 'Only me', description: 'Hidden from your team.', icon: 'lock' }, { value: 'team', label: 'My team', description: 'Everyone in the workspace.', icon: 'users' }, ]}/>Examples
Without icons
Text only, and one option disabled: it stays in place, faded, and its description says why.
import { useState } from 'react'import { RadioCards } from '@rchursin/product/forms'
type Cadence = 'instant' | 'daily' | 'weekly'
/** Without icons, and with one option disabled: it stays in place, faded, and says why. */export default function RadioCardsDigest() { const [cadence, setCadence] = useState<Cadence>('daily')
return ( <RadioCards<Cadence> legend="Send reminders" className="w-full" value={cadence} onChange={setCadence} options={[ { value: 'instant', label: 'As they happen', description: 'One email for each reminder, at its time.' }, { value: 'daily', label: 'Daily digest', description: 'One email at 8:00 with the day ahead.' }, { value: 'weekly', label: 'Weekly digest', description: 'Not on your plan yet.', disabled: true }, ]} /> )}An error and two columns
Nothing is chosen at first. Press Save without choosing: the error appears under the legend and focus moves to the first card, whose id an error summary would link to.
import { useId, useState, type FormEvent } from 'react'import { RadioCards } from '@rchursin/product/forms'import { Button } from '@rchursin/product/ui'
type Channel = 'email' | 'inbox'
/** * No card is chosen at first (value ''), so Save can ask for a choice. The error sits under the * legend and describes the fieldset; `id` is the first card's radio, the target an error summary * links to. columns={2} keeps the two cards side by side from 640px wide. */export default function RadioCardsRequired() { const id = useId() const [channel, setChannel] = useState<Channel | ''>('') const [tried, setTried] = useState(false) const [saved, setSaved] = useState(false) const error = tried && !channel ? 'Choose where to send failed-run alerts' : undefined
function save(e: FormEvent) { e.preventDefault() // a sample: nothing leaves the pageAPI
Radio cards props
| Prop | Type | Default | Description |
|---|---|---|---|
legendrequired | ReactNode | – | The question, rendered as the fieldset’s <legend>. |
optionsrequired | RadioCardOption<V>[] | – | One card each; see the table below. |
valuerequired | V | – | The chosen card’s value. |
onChangerequired | (value: V) => void | – | Called with the new value. |
name | string | useId() | The radios’ shared name, sent with a form. Ids never come from it, so two groups may share a name. |
id | string | useId() | The first radio’s id, so an error summary can link to it; the others get -2, -3 and so on. |
error | ReactNode | – | An error under the legend, with an icon and a hidden “Error:”, linked to the fieldset with aria-describedby. |
columns | 1 | 2 | – | Fixed columns from 640px wide. Default: as many cards of at least 10rem as fit. |
className | string | – | Classes on the <fieldset>. |
RadioCardOption
| Prop | Type | Default | Description |
|---|---|---|---|
valuerequired | V | – | The value passed to onChange. |
labelrequired | string | – | The card’s title and the radio’s accessible name. |
description | ReactNode | – | A sentence under the title, linked with aria-describedby. |
icon | IconName | – | A decorative icon above the title. |
disabled | boolean | – | Fades the card to 50% and skips it. Say why in the description. |
Accessibility
Keyboard
| Key | What it does |
|---|---|
| Tab | Moves into the group, onto the chosen card, and out again. |
| Down arrowRight arrow | Moves to the next card and chooses it, skipping disabled ones. |
| Up arrowLeft arrow | Moves to the previous card and chooses it. |
- Each card is a <label> around a native radio, so a click anywhere on it chooses it.
- The radio’s name comes from the title only (aria-labelledby) and the description is read after it (aria-describedby), so the name stays short.
- The radio’s own outline is off; the card draws a 2px outline instead, using :has(:focus-visible).
- An error sits under the legend with an icon and a hidden “Error:”, and the fieldset’s aria-describedby points to it, so it is read when focus enters the group (the GOV.UK radios pattern).
- Chosen and not chosen differ by ring weight and the square in the radio, not by colour.
Guidelines
Do
- Use cards when each option needs a sentence to be understood.
- Keep descriptions to one line where you can, so the cards line up.
- Use two to four cards.
Don’t
- Don’t put links or buttons inside a card: the whole card is one target.
- Don’t use cards for short labels that fit a RadioGroup or Segmented control.
In use
Related
- Radio groupA fieldset of native radios, each a ring with the brand square inside when chosen, with optional hints.
- Segmented controlA radio group drawn as one strip, with a lit pill that glides to the choice and arrow keys that move and choose.
- CheckboxA labelled checkbox drawn as the brand square with a cut-in tick, with a hint and a mixed state.
Source
The component’s own files, exactly as they run on this site.
/** * Radio cards: a radio group where each choice needs a sentence of explanation. * Native radios underneath (arrow keys move and choose, one tab stop); the whole card is the * target. The chosen card gets a firmer ring and the radio's square: shape, not colour. * * <RadioCards legend="Workspace" value={v} onChange={setV} options={[{ value, label, description, icon }]} /> * * An `error` shows under the legend (with "Error:" for screen readers) and describes the fieldset, * as in the GOV.UK radios pattern. */import { useId, type ReactNode } from "react"import { cx } from "../../components/cx"import { Icon, type IconName } from "../icons/Icon"import { ErrorText } from "../ui/fields"
export interface RadioCardOption<V extends string> { value: V label: string description?: ReactNode icon?: IconName