Skip to content

Choice card

Radio cards for choosing between a few modes, each with a description.

Status
Beta
Category
Selection
Adoption
Not used yet
import { ChoiceCard } from "@oration/canon/components/choice-card";
packages/canon/src/components/choice-card.tsx

When a call reaches voicemail

import { ChoiceCard } from "@oration/canon/components/choice-card";import { RadioGroup } from "@oration/canon/components/radio-group";import { toast } from "@oration/canon/components/toast";import * as React from "react";export function Hero() {    type Mode = "leave_message" | "hang_up" | "send_text";    const headingId = React.useId();    const [mode, setMode] = React.useState<Mode>("leave_message");    const modes: { value: Mode; title: string; description: string }[] = [        {            value: "leave_message",            title: "Leave a message",            description:                "The agent reads a short voicemail with the invoice number and a callback line.",        },        {            value: "hang_up",            title: "Hang up and try later",            description:                "No message. The supplier is called again in four hours, up to three times.",        },        {            value: "send_text",            title: "Send a text instead",            description:                "Hang up and text the payment link to the number on file.",        },    ];    return (        <section            aria-labelledby={headingId}            className="flex w-full max-w-md flex-col gap-3 text-left"        >            <h3 id={headingId} className="text-13 font-medium">                When a call reaches voicemail            </h3>            <RadioGroup                value={mode}                aria-labelledby={headingId}                onValueChange={(next) => {                    setMode(next as Mode);                    toast.add({                        title: "Voicemail behavior saved",                        description: modes.find((m) => m.value === next)?.title,                    });                }}            >                {modes.map((option) => (                    <ChoiceCard                        key={option.value}                        id={`${headingId}-${option.value}`}                        value={option.value}                        title={option.title}                        description={option.description}                    />                ))}            </RadioGroup>        </section>    );}

Usage#

Choice card turns each option of a radio choice into a card with a title and a one-line description, for picking between a few modes whose difference needs a sentence. It comes in two forms: ChoiceCard, one card you place inside a RadioGroup with optional leading and trailing slots, and ChoiceCards, a self-contained grid built from an options array. Campaign steps and agent config pages use them. The common mistake is cards for choices that explain themselves, such as days, weeks and months, where a segmented control or a plain radio group is lighter.

When to use

  • For two to five modes that change how something works, each needing a sentence: what happens when a call reaches voicemail, who places reminder calls.
  • When the choice carries a consequence worth showing in the card, such as a cost in credits or a threshold.
  • In a campaign or setup step where the choice is the main decision on the screen.
  • On agent config pages, as ChoiceCards in a grid of two to five columns.

When not to use

  • For short labels that explain themselves. Use a segmented control beside a list, or a radio group in a form. Use Segmented control
  • For a plain list of options in a form. Use Radio group
  • For more than five options. Use Select field
  • For a setting that is on or off. Use Switch
  • When several options can be chosen. Choice cards are single choice only. Use Checkbox

The Quiet Indigo Rule

Selection is one of the few places indigo is spent: the chosen card takes an indigo ring or border, a faint indigo tint and an indigo radio. Icons inside cards stay Slate Meta.

The Hairline-and-Lift Rule

A card takes its edge from the composite hairline shadow, as ChoiceCards does. ChoiceCard still draws a CSS border; see Known gaps.

Anatomy#

  1. Card. The whole card is the label, so a click anywhere selects. ChoiceCard: 10px corners, a 1px border and 10px padding. ChoiceCards: 12px corners, the hairline lift and 12px padding.
  2. Radio. A 16px radio at the top left. It fills with indigo when chosen.
  3. Title. 14px medium ink, one line. In ChoiceCards, an optional 14px Slate Meta icon leads it.
  4. Description. 13px Slate Meta, one sentence, wraps with text-pretty.
  5. Trailing. ChoiceCard only: a node at the right, such as a cost or a threshold in 12px tabular figures. leading adds one between the radio and the title.

Examples#

A grid from options

ChoiceCards takes an options array and draws its own radio group. Each option can carry a 14px icon and a one-line description.

import { ChoiceCards } from "@oration/canon/components/choice-cards";import { toast } from "@oration/canon/components/toast";import { BanknoteIcon, FileTextIcon, LandmarkIcon } from "lucide-react";import * as React from "react";export function Cards() {    type Method = "ach" | "wire" | "check";    const [method, setMethod] = React.useState<Method>("ach");    return (        <div className="w-full max-w-2xl text-left">            <ChoiceCards<Method>                label="Default payment method"                columns={3}                value={method}                onValueChange={(next) => {                    setMethod(next);                    toast.add({ title: "Default payment method saved" });                }}                options={[                    {                        value: "ach",                        label: "ACH transfer",                        description: "Two business days. No fee.",                        icon: <LandmarkIcon aria-hidden="true" />,                    },                    {                        value: "wire",                        label: "Wire",                        description:                            "Same day before 3 PM CT. $15 per payment.",                        icon: <BanknoteIcon aria-hidden="true" />,                    },                    {                        value: "check",                        label: "Paper check",                        description: "Mailed in five to seven days.",                        icon: <FileTextIcon aria-hidden="true" />,                    },                ]}            />        </div>    );}

Columns

One column below 640px. columns sets the grid above it: 2 and 3 at once, 4 as two then four from 1024px, 5 as three then five.

import { ChoiceCards } from "@oration/canon/components/choice-cards";import * as React from "react";export function Columns() {    type Handoff = "agent" | "queue" | "owner" | "voicemail";    const [handoff, setHandoff] = React.useState<Handoff>("queue");    return (        <div className="w-full max-w-3xl text-left">            <ChoiceCards<Handoff>                label="When the agent can't resolve a call"                columns={4}                value={handoff}                onValueChange={setHandoff}                options={[                    {                        value: "agent",                        label: "Keep trying",                        description:                            "The agent asks once more, then offers a callback.",                    },                    {                        value: "queue",                        label: "AP exceptions",                        description: "Warm transfer to the exceptions queue.",                    },                    {                        value: "owner",                        label: "Account owner",                        description: "Ring the supplier's owner at Cedarline.",                    },                    {                        value: "voicemail",                        label: "Voicemail",                        description: "Take a message and open a task.",                    },                ]}            />        </div>    );}

Leading and trailing slots

ChoiceCard sits inside a RadioGroup and takes leading and trailing nodes, for an icon tile and a cost or threshold in tabular figures.

Who places the reminder calls

import { ChoiceCard } from "@oration/canon/components/choice-card";import { RadioGroup } from "@oration/canon/components/radio-group";import { BotIcon, UserRoundIcon } from "lucide-react";import * as React from "react";export function LeadingAndTrailing() {    type Mode = "agent" | "human";    const headingId = React.useId();    const [mode, setMode] = React.useState<Mode>("agent");    return (        <section            aria-labelledby={headingId}            className="flex w-full max-w-md flex-col gap-3 text-left"        >            <h3 id={headingId} className="text-13 font-medium">                Who places the reminder calls            </h3>            <RadioGroup                value={mode}                aria-labelledby={headingId}                onValueChange={(next) => setMode(next as Mode)}            >                <ChoiceCard                    id={`${headingId}-agent`}                    value="agent"                    title="Collections agent"                    description="Calls every overdue supplier and logs each outcome."                    leading={                        <span className="flex size-8 shrink-0 items-center justify-center rounded-[10px] bg-muted text-muted-foreground">                            <BotIcon aria-hidden="true" className="size-4" />                        </span>                    }                    trailing={                        <span className="shrink-0 text-xs text-muted-foreground tabular-nums">                            About 1,200 credits a week                        </span>                    }                />                <ChoiceCard                    id={`${headingId}-human`}                    value="human"                    title="Your AP team"                    description="Calls land as tasks for Priya Raman and Aisha Bello."                    leading={                        <span className="flex size-8 shrink-0 items-center justify-center rounded-[10px] bg-muted text-muted-foreground">                            <UserRoundIcon                                aria-hidden="true"                                className="size-4"                            />                        </span>                    }                    trailing={                        <span className="shrink-0 text-xs text-muted-foreground tabular-nums">                            No credits                        </span>                    }                />            </RadioGroup>        </section>    );}

The two treatments

The same choice in each component. ChoiceCard draws a bordered 10px card; ChoiceCards uses the hairline lift, 12px corners and a 1.5px indigo ring. Keep one per surface.

ChoiceCard

ChoiceCards

import { ChoiceCard } from "@oration/canon/components/choice-card";import { ChoiceCards } from "@oration/canon/components/choice-cards";import { RadioGroup } from "@oration/canon/components/radio-group";import * as React from "react";export function Treatments() {    const headingId = React.useId();    const [left, setLeft] = React.useState("warm");    const [right, setRight] = React.useState<"warm" | "cold">("warm");    return (        <div className="grid w-full max-w-2xl gap-6 text-left sm:grid-cols-2">            <div className="flex flex-col gap-2">                <p id={headingId} className="font-mono text-xs">                    ChoiceCard                </p>                <RadioGroup                    value={left}                    aria-labelledby={headingId}                    onValueChange={(next) => setLeft(String(next))}                >                    <ChoiceCard                        id={`${headingId}-warm`}                        value="warm"                        title="Warm transfer"                        description="The agent stays on and introduces the caller."                    />                    <ChoiceCard                        id={`${headingId}-cold`}                        value="cold"                        title="Cold transfer"                        description="The agent drops off when the call rings."                    />                </RadioGroup>            </div>            <div className="flex flex-col gap-2">                <p className="font-mono text-xs">ChoiceCards</p>                <ChoiceCards                    label="Transfer type"                    columns={2}                    className="sm:grid-cols-1"                    value={right}                    onValueChange={setRight}                    options={[                        {                            value: "warm",                            label: "Warm transfer",                            description:                                "The agent stays on and introduces the caller.",                        },                        {                            value: "cold",                            label: "Cold transfer",                            description:                                "The agent drops off when the call rings.",                        },                    ]}                />            </div>        </div>    );}

States#

States
StateTreatment
RestChoiceCard: Card White with a hairline border. ChoiceCards: Card White with the hairline lift.
HoverChoiceCard fills Well Gray at 50%. ChoiceCards deepens to the hover lift over 150ms.
CheckedChoiceCard: indigo border at 30% and a 5% indigo tint. ChoiceCards: a 1.5px indigo ring and a 4% indigo tint. The radio fills with indigo in both.
Focus visibleChoiceCard: an indigo border and a 3px ring at 50% on the card. ChoiceCards: a 3px Focus Indigo ring at 40%.
DisabledChoiceCard dims to 50% when its RadioGroup is disabled. ChoiceCards has no disabled state.

Behavior#

  • ChoiceCard must be a child of RadioGroup, which owns value, onValueChange and the keyboard. Each card needs a unique id; it links the label to the radio.
  • ChoiceCards is controlled with value and onValueChange and needs a label for the group. The value type narrows to the option values.
  • In both, arrow keys move between cards and select as they go, and only the chosen card is in the tab order.
  • ChoiceCards lays out one column below 640px. columns sets the grid above that: 2 or 3 from 640px, 4 as two then four from 1024px, 5 as three then five.
  • ChoiceCards renders buttons with radio roles, not native inputs, so it doesn't submit with a native form.

Do and don't#

Do. Use cards when each option needs a sentence to choose well.
Don't. Put one-word options with nothing to explain in cards. Three cards that say Days, Weeks and Months are a segmented control.
Do. Keep one treatment per surface: ChoiceCards on config pages, ChoiceCard in campaign steps.
Don't. Mix the bordered and the lifted card on one screen. They read as two different controls.

Content#

  • Titles name the outcome in a few words, sentence case: Leave a message, Hang up and try later.
  • Descriptions are one sentence on what happens, in plain words: The supplier is called again in four hours, up to three times.
  • Head the group with a question or a situation: When a call reaches voicemail.
  • Put costs and thresholds in the trailing slot in tabular figures, not in the title.

Accessibility#

  • ChoiceCard uses Base UI Radio: each card is a <label> for a role="radio". Name the RadioGroup with aria-labelledby pointing at its heading.
  • ChoiceCards renders role="radiogroup" with aria-label from label, and each card is a <button role="radio"> with aria-checked.
  • The title and description are both inside the label, so screen readers read the description as part of the option's name. Keep it to one sentence.
  • Mark icons aria-hidden="true".
  • The whole card is the hit area, well above the 24px minimum.
Keyboard interactions
KeysAction
TabMoves focus to the chosen card, then out of the group.
↓→Selects the next card, wrapping to the first.
↑←Selects the previous card, wrapping to the last.
SpaceSelects the focused card.

Design tokens#

Design tokens
TokenUsed for
--primaryChecked ring or border, tint and radio
--cardCard fill
shadow-borderChoiceCards edge at rest
shadow-border-hoverChoiceCards edge on hover
--borderChoiceCard edge
--mutedChoiceCard hover fill at 50%
--inputUnchecked radio ring
--ringFocus ring
--muted-foregroundDescription and icon
--radius-xl12px ChoiceCards corners; ChoiceCard uses 10px

API reference#

ChoiceCard

One radio card. Place it inside RadioGroup from @oration/canon/components/radio-group. Its props are exported as ChoiceCardProps.

Props of ChoiceCard
PropTypeDefaultDescription
idRequiredstringNo defaultUnique id that links the card's label to its radio.
valueRequiredstringNo defaultThe value the group reports when this card is chosen.
titleRequiredReactNodeNo defaultThe option's name.
descriptionReactNodeNo defaultOne sentence on what the option does.
leadingReactNodeNo defaultRendered between the radio and the title, such as an icon tile.
trailingReactNodeNo defaultRendered at the right, such as a cost or threshold.

ChoiceCards

A self-contained grid of radio cards. Generic over the value type V extends string.

Props of ChoiceCards
PropTypeDefaultDescription
valueRequiredVNo defaultThe chosen option's value.
onValueChangeRequired(value: V) => voidNo defaultCalled on click and arrow keys.
optionsRequired{ value: V; label: string; description?: ReactNode; icon?: ReactNode }[]No defaultThe cards, in order.
labelRequiredstringNo defaultThe radio group's accessible name.
columns2 | 3 | 4 | 52Grid columns from 640px up. Always one column below.
classNamestringNo defaultMerged onto the grid.

Known gaps#

Where the implementation and the system disagree today. Follow the system, not the gap.

Two components, two looks. ChoiceCard draws a CSS border with 10px corners, breaking the Hairline-and-Lift Rule; ChoiceCards uses the hairline lift with 12px corners. The checked states differ too.

ChoiceCard passes text-13 to FieldDescription through the cn package, which treats it as a color and drops text-muted-foreground, so the description renders in ink instead of Slate Meta.

Neither has a per-option disabled, and ChoiceCards can't be disabled at all.

ChoiceCards isn't built on Base UI Radio, so it has no hidden input and doesn't post with a native form.