Components · Forms

Radio card

A radio button on a tap-target sized for content. Use when a single-select option needs more room than a list row — repayment plans, pricing tiers, density modes, anywhere the choice carries supporting copy.

Documentedby Derek Fidler

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.

9% of daily salesabout 14 months to complete45.000 kr service fee
17% of daily salesabout 7 months to complete62.500 kr service fee
22% of daily salesabout 5 months to complete68.000 kr service fee

Vertical · default

Stacked, full-width cards. Use when descriptions or metas are long enough to wrap.

CompactThe most rows on screen
DefaultBalanced for everyday work
SpaciousGenerous spacing, easier scanning

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 is role="radio" with aria-checked.
  • Group label is required. Pass either aria-label or aria-labelledby on <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 name to 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

13% of daily salesabout 10 months to complete repayments55.000 kr service fee
17% of daily salesabout 7 months to complete repayments62.500 kr service fee

Do

Write specific, comparable copy for every option. The user picks by reading — abstract labels force a click to find out what they mean.

StandardMost popular choiceSee details
PremiumBetter for some teamsSee details

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.

MonthlyOne payment per month
DailyPay-as-you-earn
CustomPick your own schedule

Do

Cap the group at three to five options. Above that, the cards stop being scannable and a Select control reads better.

Plan 1Plan 2Plan 3Plan 4Plan 5Plan 6Plan 7Plan 8

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>

PropTypeDefaultDescription
value*string | undefinedThe currently selected card's value. Compared by strict equality.
onValueChange*(value: string) => voidFires 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.
namestringWhen set, each card renders a hidden <input type='radio'> with this name so the value flows through native form submission.
aria-labelstringAccessible name for the group. Required if no aria-labelledby.
aria-labelledbystringAlternative to aria-label — points at an element that names the group.
children*ReactNodeRadioCard elements.

<RadioCard>

PropTypeDefaultDescription
value*stringThe value this card represents.
selectedbooleanStandalone mode only — whether this card is selected. Ignored when inside a RadioCardGroup.
onSelect() => voidStandalone mode only — fires when the card is picked. Ignored when inside a RadioCardGroup.
children*ReactNodeThe title — Inter Tight Semibold 16 px.
descriptionReactNodeOptional supporting line below the title. Wraps freely.
metaReactNodeOptional second supporting line below the description. Truncates with ellipsis when it overflows.
disabledbooleanfalseDisables click and keyboard activation, drops the card to a frozen state.