Click to choose
Overview
The Radio card lives in @flatpay-dk/ui and ships as <RadioCard> plus <RadioCardGroup>. A card pairs a 20 px radio target on the left with a title, an optional description, and an optional truncating meta line — the three-line layout from the Figma. The group is a real WAI-ARIA radiogroup: arrow keys move between cards and select them, Tab moves out.
When to reach for it
Use a radio card when each option carries supporting copy that wouldn’t fit on a single list row — pricing tiers, plan picks, repayment models. For binary or short-label single-selects (Newest first / Oldest first), use Radio button in a list. For toggling several options at once, use Checkbox.
Anatomy
The card geometry mirrors the Figma exactly — 8 px radius, 1 px border, 20 px padding. The radio indicator sits in a 20 × 20 box aligned to the title’s cap-height; content fills the rest.
- Indicator. 20 × 20 circle, 1.5 px stroke. Black ring with a 8 px filled dot when selected; mid-neutral ring when hovered; light-neutral ring at rest. Disabled drops chroma.
- Title. Inter Tight Semibold 16 px / leading-snug — primary colour at rest and when selected, dropped to 30% opacity when disabled.
- Description. Optional Inter Tight Regular 14 px,
text-foreground/65. Wraps freely. Use it for the trade-off the title doesn’t already carry. - Meta. Optional second supporting line, also 14 px secondary. Truncates with ellipsis when it overflows — fine for amounts, account refs, anything where the value matters more than wrapping.
States
Seven visible states across the lifecycle. Selected is the only state that changes the border to charcoal; everything else moves inside the neutral ramp. Focus draws a 2 px blurple ring with a 2 px offset — the system focus pattern.
- 17% of daily salesabout 7 months to complete repayments62.500 kr service fee
Rest
Default unselected
- 17% of daily salesabout 7 months to complete repayments62.500 kr service fee
Hover
Pointer over the card
- 17% of daily salesabout 7 months to complete repayments62.500 kr service fee
Active
Mouse-down on selection
- 17% of daily salesabout 7 months to complete repayments62.500 kr service fee
Focus
Keyboard focus visible ring
- 17% of daily salesabout 7 months to complete repayments62.500 kr service fee
Selected
Committed choice
- 17% of daily salesabout 7 months to complete repayments62.500 kr service fee
Disabled
Not available
- 17% of daily salesabout 7 months to complete repayments62.500 kr service fee
Disabled · selected
Frozen choice
Layout
The group lays out vertically by default — a stack of full-width cards. Pass orientation="horizontal" for short labels in a side-by-side layout (density pickers, billing interval pickers). Cards inside a horizontal group share equal width.
Vertical · default
Stacked, full-width cards. Use when descriptions or metas are long enough to wrap.
Horizontal · short labels
Three options across. Drop the meta line when you go horizontal — the cards lose room fast.
Behavior
- Pick. Click a card or focus it and press Space / Enter. The group updates immediately; the previously-selected card returns to rest.
- Move with arrows. ↑ / ↓ (or ← / →) move focus between cards and commit the new selection — the native radio pattern. Home / End jump to the first / last card.
- Tab. Tab moves out of the group entirely. Only one card is in the tab order at a time — the selected one. This is the WAI-ARIA radiogroup pattern.
- Disabled.A disabled card is skipped by both click and arrow-key navigation; it can’t take focus.
Accessibility
- Roles. The group renders
role="radiogroup"; each card isrole="radio"witharia-checked. - Group label is required. Pass either
aria-labeloraria-labelledbyon<RadioCardGroup>so screen readers announce what the group is for. - Tab-stop discipline. Only the selected card is in the tab order. When nothing is selected, the first card takes the tab stop — the native radiogroup convention.
- Native form integration. Pass a
nameto the group and the cards render a hidden<input type="radio">alongside, so the value flows through native form submit.
Code
The group owns the value; cards declare their own. Description and meta are optional ReactNode slots.
tsx
import { RadioCard, RadioCardGroup } from "@flatpay-dk/ui";
const [plan, setPlan] = useState("17");
<RadioCardGroup
value={plan}
onValueChange={setPlan}
aria-label="Repayment plan"
>
<RadioCard
value="9"
description="about 14 months to complete repayments"
meta="45.000 kr service fee"
>
9% of daily sales
</RadioCard>
<RadioCard
value="13"
description="about 10 months to complete repayments"
meta="55.000 kr service fee"
>
13% of daily sales
</RadioCard>
<RadioCard
value="17"
description="about 7 months to complete repayments"
meta="62.500 kr service fee"
>
17% of daily sales
</RadioCard>
</RadioCardGroup>
// Horizontal layout — drop the meta line when you go side-by-side
<RadioCardGroup
value={density}
onValueChange={setDensity}
orientation="horizontal"
aria-label="Density"
>
<RadioCard value="compact" description="The most rows on screen">Compact</RadioCard>
<RadioCard value="default" description="Balanced for everyday work">Default</RadioCard>
<RadioCard value="spacious" description="Generous spacing, easier scanning">Spacious</RadioCard>
</RadioCardGroup>
// Standalone — cards manage their own selected/onSelect, no group
<RadioCard
value="manual"
selected={mode === "manual"}
onSelect={() => setMode("manual")}
>
Manual review
</RadioCard>Best practices
Do
Write specific, comparable copy for every option. The user picks by reading — abstract labels force a click to find out what they mean.
Don't
Don't use vague titles or hide the trade-off behind 'See details' on every card. The card exists so the choice is legible inline.
Do
Cap the group at three to five options. Above that, the cards stop being scannable and a Select control reads better.
Don't
Don't use radio cards for long lists. Eight repayment plans is a Select with descriptions, not eight cards.
- Keep titles parallel. If one card is 17% of daily sales, the others should follow the same pattern (13% of daily sales, 22% of daily sales). Same shape = comparable choice.
- Don’t open with the meta. The first supporting line is the trade-off; the second is the value. Reversing them buries the reason for the choice.
- Pre-select a default when there is one. A blank group makes the user pick before they understand the consequences. Pick a sensible default and let them change it.
- Avoid mixing selected and disabled-selected. Disabled-selected is a frozen choice (e.g. an admin-locked plan). If that case isn’t real for the surface, you don’t need the state.
Props
<RadioCardGroup>
| Prop | Type | Default | Description |
|---|---|---|---|
| value* | string | undefined | — | The currently selected card's value. Compared by strict equality. |
| onValueChange* | (value: string) => void | — | Fires when the user picks a new card via click or arrow-key navigation. |
| orientation | "vertical" | "horizontal" | "vertical" | Layout direction. Horizontal cards share equal width; vertical cards stretch to the group's width. |
| name | string | — | When set, each card renders a hidden <input type='radio'> with this name so the value flows through native form submission. |
| aria-label | string | — | Accessible name for the group. Required if no aria-labelledby. |
| aria-labelledby | string | — | Alternative to aria-label — points at an element that names the group. |
| children* | ReactNode | — | RadioCard elements. |
<RadioCard>
| Prop | Type | Default | Description |
|---|---|---|---|
| value* | string | — | The value this card represents. |
| selected | boolean | — | Standalone mode only — whether this card is selected. Ignored when inside a RadioCardGroup. |
| onSelect | () => void | — | Standalone mode only — fires when the card is picked. Ignored when inside a RadioCardGroup. |
| children* | ReactNode | — | The title — Inter Tight Semibold 16 px. |
| description | ReactNode | — | Optional supporting line below the title. Wraps freely. |
| meta | ReactNode | — | Optional second supporting line below the description. Truncates with ellipsis when it overflows. |
| disabled | boolean | false | Disables click and keyboard activation, drops the card to a frozen state. |