Components · Forms

Choice list

A labelled group of selection items — radio buttons, checkboxes, or radio cards — wrapped with a question label, optional subline, and an inline error slot. The wrapper that turns a stack of marks into a form field.

Documentedby Derek Fidler

POS menu visibility

Show the product description in the POS menu.

Show descriptionAdds a 14 px caption beneath each item.
Title onlyCompact rows, easier to scan in a busy lane.
Hide menuUntil you re-enable it from settings.

Overview

A choice list is a question and its answers. The header names the question; the items list the answers; the optional inline message handles validation errors. Three item types ship in the same wrapper — radio button, checkbox, and radio card — and each one tells the user a different thing about how the selection works.

Reach for choice list, not a bare radio group

Use the choice list whenever the question deserves a label, a description, or error feedback. A bare RadioGroup works fine for embedded picks (an inline filter, a dense settings row), but the moment the question needs an explainer, reach for the choice list — it carries that copy in the right place at the right size.

Anatomy

Three regions stacked vertically: the header up top, the items in the middle, and the optional inline error at the bottom. 16 px gap between every adjacent pair, regardless of which region is showing.

POS menu visibility

Show the product description in the POS menu.

Show description
Title only
  1. Label

    Inter Tight Medium 16 / 24, foreground. The question. Sentence case, never a sentence — labels, not paragraphs.

  2. Subline (optional)

    Inter Tight Regular 14 / 20, text.secondary. One line. Use for a constraint, a price, a soft hint.

  3. Items

    Radio buttons (vertical, 20 px gap), checkboxes (vertical, 20 px gap), or radio cards (horizontal on desktop, 16 px gap; vertical on mobile).

  4. Inline error (optional)

    20 px error_outline icon + helper copy in text.danger.bolder (red.900). Sits below the last item; the items themselves carry the error tone above it.

Types

Three item types share the same wrapper. Pick by the selection model — exclusive vs not — and by how much each option needs to explain itself.

Type · radio button

Mutually exclusive. One option is always selected. 3–5 options.

Payout schedule

When the daily settlement should land.

Same dayAvailable before 18:00 CET on weekdays.
Next business dayLands by 09:00 the morning after.
WeeklyBundled into one transfer every Monday morning.

Type · checkbox

Multi-select. Zero or more options. Use when picks aren't exclusive.

Receipt options

What to include on customer-facing receipts.

Include VAT breakdownRequired for B2B customers in Denmark and Germany.
Include itemised linesLists each cart item with quantity and price.
Include refund policyOne-line legal text linking to the storefront's policy.

Type · radio card

Same selection model as radio button, with richer per-option content. Lay out horizontally on desktop, stack on mobile.

Repayment plan

Pick the plan that fits your daily revenue.

17% of daily salesabout 7 months to complete repayments75.000 kr service fee
15% of daily salesabout 7 months to complete repayments62.500 kr service fee
12% of daily salesabout 7 months to complete repayments62.500 kr service fee

Header is optional. Use a label whenever the question isn't already obvious from the page; add the subline when the label needs a constraint, a price, or a soft hint that doesn't fit on each item.

Label + subline

POS menu visibility

Show the product description in the POS menu.

Show description
Title only

Label only

POS menu visibility

Show description
Title only

No header

Show description
Title only

Use only when the question is already established by surrounding structure — a section heading, a wizard step.

Disabled

POS menu visibility

Available once your POS is connected.

Show description
Title only

Error

When the user submits without picking (or with an invalid pick), the choice list flips into error mode: every item gets the danger ring, and a single inline message appears at the bottom. Don't paint required-but-untouched groups red preemptively.

Error · radio button

Pickup or delivery

Pick one to continue.

Pickup
Delivery

Choose pickup or delivery to continue.

Apply the error tone to the whole group at once. The inline message sits below the last option with a 20 px error icon and a single line of helper copy in red.900.

Behavior

  • Selection model is fixed by type. Radio button + radio card = mutually exclusive, one always selected. Checkbox = multi-select, zero or more. Don't mix types in the same list.
  • Whole row is the click target. Clicking the title, the help text, or the radio card's body all toggle the selection — same as clicking the mark. Hit area extends the row width.
  • Error tone applies to the whole group. When validation fails, every item ring goes red and a single inline message names the problem. Don't mark just one item — the user picked nothing, not the wrong thing.
  • Radio cards lay out horizontally on desktop, stack on mobile. They need room to breathe — three lines of supporting text and a ~20 px radio mark per card. Below 600 px viewport, the cards stack and take the full row.
  • Default to a sensible pick for radio types. The list never ships empty. For checkboxes, default state is whatever the user last saved (or all-off, never all-on for opt-ins).

Accessibility

  • Wrap in <fieldset> with <legend> so screen readers announce the question before the options. The label and subline both live inside the legend; the subline gets a visually-hidden colon between them.
  • Native inputs underneath. <input type="radio"> with a shared name, or <input type="checkbox"> for the multi case. The browser handles arrow-key nav (radios) and Space-to-toggle (checkboxes) for free.
  • Subline links via aria-describedby on the fieldset, pointing at the subline's id. Screen readers announce it together with the legend.
  • Error linking: aria-invalid="true" on the fieldset and aria-describedby extended to include the error message. Wrap the message in aria-live="polite" so it announces when it appears.
  • Disabled options stay in the DOM with aria-disabled="true" so screen readers announce them. Pair with helper copy explaining why — “Available once your POS is connected.”
  • Tap target ≥ 44 px. The mark is 20 px; the row stretches the click area to the full row height + width.

Code

One ChoiceList wrapper, three item primitives. The wrapper handles header, error, and the fieldset/legend semantics; the items handle their own marks and labels.

tsx

import { ChoiceList, Radio, Checkbox, RadioCard } from "@flatpay-dk/ui";

// Radio buttons
const [schedule, setSchedule] = useState("next");
<ChoiceList
  label="Payout schedule"
  subline="When the daily settlement should land."
  value={schedule}
  onValueChange={setSchedule}
>
  <Radio value="same" title="Same day" help="Available before 18:00 CET on weekdays." />
  <Radio value="next" title="Next business day" help="Lands by 09:00 the morning after." />
  <Radio value="weekly" title="Weekly" help="Bundled into one transfer every Monday." />
</ChoiceList>

// Checkboxes (multi-select)
const [options, setOptions] = useState(["vat", "policy"]);
<ChoiceList
  label="Receipt options"
  subline="What to include on customer-facing receipts."
  value={options}
  onValueChange={setOptions}
  type="checkbox"
>
  <Checkbox value="vat" title="Include VAT breakdown" />
  <Checkbox value="lines" title="Include itemised lines" />
  <Checkbox value="policy" title="Include refund policy" />
</ChoiceList>

// Radio cards (richer per-option content)
<ChoiceList
  label="Repayment plan"
  subline="Pick the plan that fits your daily revenue."
  value={plan}
  onValueChange={setPlan}
  orientation="horizontal"
>
  <RadioCard
    value="17"
    title="17% of daily sales"
    supporting="about 7 months to complete repayments"
    meta="75.000 kr service fee"
  />
  <RadioCard value="15" title="15% of daily sales" supporting="…" meta="…" />
  <RadioCard value="12" title="12% of daily sales" supporting="…" meta="…" />
</ChoiceList>

// Error state
<ChoiceList
  label="Pickup or delivery"
  value={method}
  onValueChange={setMethod}
  error="Choose pickup or delivery to continue."
>
  <Radio value="pickup" title="Pickup" />
  <Radio value="delivery" title="Delivery" />
</ChoiceList>

Best practices

Choice lists carry a lot of forms. Small habits compound into predictable, scannable surfaces.

Payout schedule

When the daily settlement should land.

Same day
Next business day

Do

Lead with the question. The label is the first thing the user reads — make it answerable in one line.

Choose how often you want your daily payout to be deposited into your linked bank account

Same day
Next business day

Don't

Don't dump instructions into the label. The label is a question; instructions go in the subline (one line) or page copy above.

17% of daily salesabout 7 months to complete75.000 kr service fee

Do

Use radio cards when each option needs three lines of supporting copy. The card carries a price, a duration, a constraint.

Same day
Send email confirmation
Next business day

Don't

Don't mix item types in one list. A checkbox next to two radios reads as a bug, not a feature.

Pickup or delivery

Pickup
Delivery

Choose pickup or delivery to continue.

Do

One inline error per group, named action — 'Choose pickup or delivery'. The user knows what to do next.

Pickup or delivery (required)

Pickup
Delivery

Required field

Don't

Don't preemptively paint required groups red. Save the error tone for after the user submits without picking.

Props

ChoiceList

PropTypeDefaultDescription
labelstringThe question. Inter Tight Medium 16/24. Renders as <legend>.
sublinestringOptional one-line clarification. Inter Tight Regular 14/20 in text.secondary. Linked via aria-describedby.
type"radio" | "checkbox" | "radio-card""radio"Selection model. radio + radio-card are mutually exclusive; checkbox is multi-select.
orientation"vertical" | "horizontal""vertical"Item layout. Vertical for radio + checkbox; horizontal is reserved for radio cards on desktop (stacks below 600 px).
valuestring | string[]Currently selected value (radio) or values (checkbox). Make the list controlled — never internally empty for radio types.
onValueChange(value: string | string[]) => voidFires on selection change. Receives the new value(s), not the event.
errorstringHelper text in danger tone below the last item. Pairs every item with the error border.
namestringForm field name. Required for form submission; auto-generated if omitted.
disabledbooleanfalseDisables every item. For per-item disable, use disabled on the individual Radio/Checkbox/RadioCard.