Choice Card
A radio or a checkbox whose whole row is the target. The card carries the role, so a tap anywhere in it answers — and the same card renders a submitted answer, or a picture of a form with no control at all, without looking disabled.
Import
import {
RadioGroupCard,
CheckboxCard,
} from "@mikenotthepope/substrateui"Composition
RadioGroup
└── RadioGroupCardCheckboxCard has no root of its own — a single one stands alone, and a set of them belongs in a Fieldset so the set has a name.
The card is the control
RadioGroupItem and Checkbox are 18px boxes that a caller pairs with a Label beside them, and the target is the box. Here the card itself carries role="radio" — it is what Radio.Root renders — so the whole row answers, description included, and the target is min-h-11: a 44px floor, which is WCAG 2.2's enhanced target size (SC 2.5.5) rather than the 24px minimum (SC 2.5.8). A one-line card measures 56px, because the padding and the line box exceed the floor. The hand-rolled rows this replaces measured 36px — enough for SC 2.5.8, short of SC 2.5.5 — so the card is a change of look, not a like-for-like port.
Because the control did not move, neither did the keyboard: the group is one tab stop, arrow keys move and select within it, and Spacetoggles a checkbox card. That is Base UI's, not ours.
Several answers
A submitted answer: readOnly
An answer somebody gave, which a reader should still be able to read: the same card with readOnly on the group. It keeps its role, its aria-checked and its place in the tab order, and gains aria-readonly, so a screen reader can still say which option was chosen. Nothing is dimmed. This is Base UI's own prop — the library adds only the refusal to grey it out.
A picture of a form: presentational
A staff preview of a questionnaire, or a published record waiting on answers, is not a form at all — nobody is being asked anything. A control there is the wrong accessibility object: a radiogroup nobody can answer is a tab stop that leads nowhere and a question announced to a reader who was not asked it. presentational is the same card with no role, no ARIA state and no tab stop, and no group needed around it. selected fills the mark, because there is no control to read it from.
It is still not disabled. A preview of a form is not a disabled form, and dimming it says something untrue about every option on the page.
Every mode comes off one recipe, so the picture and the control cannot drift apart. That is the pair this component exists to delete: the applications that hand-rolled this kept a test whose whole job was to assert two class strings stayed equal.
Two options is not a special case
A true/false question is a single-select whose two options the caller wrote, so it is an ordinary radio group and nothing here knows about booleans.
Disabled, for contrast
Naming
The label and the description both sit inside the element that carries the role, so the card points aria-labelledby at the label alone and aria-describedby at the description. The name is the option; the description describes it. Pass your own aria-label and it wins; pass your own aria-describedbyand it is appended to the card's.
The group still needs a name of its own — the question being asked. Put it on the RadioGroup as an aria-label, or in a Fieldset legend.
Selection is a fill, not a border
A picked card is bg-accent with a filled mark, rather than a border-primary edge. A fill changes the whole card at a glance, where a recoloured 2px edge is easy to miss.
API Reference
| Prop | Type | Default | Description |
|---|---|---|---|
children | React.ReactNode | — | The option, as the reader sees it. Becomes the card's accessible name. |
description | React.ReactNode | — | A muted second line under the label. Becomes the card's accessible description, not part of its name. |
presentational | boolean | false | Draw the card with no control in it: no role, no ARIA state, no tab stop. For a picture of a form — a staff preview, a published record waiting on answers. Dims nothing. |
selected | boolean | false | With `presentational`, whether the mark is drawn filled. There is no control to read it from. |
readOnly | boolean | false | Freeze a submitted answer. Base UI's: sets `aria-readonly`, keeps the role, the tick and the tab stop, and dims nothing. Set it on the RadioGroup to freeze a whole group at once. |
disabled | boolean | false | The option is unavailable. Dims the card and refuses the pointer — which is what readOnly deliberately does not do. |
value | string | — | Required on RadioGroupCard: the value the group reports when this card is picked. Optional on CheckboxCard, where it is the value submitted while ticked. |
className | string | ((state) => string) | — | Merged onto the card. The function form is Base UI's, called with the card's own state — `checked`, `disabled`, `readOnly`. |
...primitive props | Radio.Root | Checkbox.Root props | — | Everything else is forwarded to the Base UI primitive: `name`, `required`, `checked` / `onCheckedChange` on the checkbox, `render`, `inputRef`. |