Choice card
Radio cards for choosing between a few modes, each with a description.
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
ChoiceCardsin 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
The Hairline-and-Lift Rule
ChoiceCards does. ChoiceCard still draws a CSS border; see Known gaps.Anatomy#
- 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. - Radio. A 16px radio at the top left. It fills with indigo when chosen.
- Title. 14px medium ink, one line. In
ChoiceCards, an optional 14px Slate Meta icon leads it. - Description. 13px Slate Meta, one sentence, wraps with
text-pretty. - Trailing.
ChoiceCardonly: a node at the right, such as a cost or a threshold in 12px tabular figures.leadingadds 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#
| State | Treatment |
|---|---|
| Rest | ChoiceCard: Card White with a hairline border. ChoiceCards: Card White with the hairline lift. |
| Hover | ChoiceCard fills Well Gray at 50%. ChoiceCards deepens to the hover lift over 150ms. |
| Checked | ChoiceCard: 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 visible | ChoiceCard: an indigo border and a 3px ring at 50% on the card. ChoiceCards: a 3px Focus Indigo ring at 40%. |
| Disabled | ChoiceCard dims to 50% when its RadioGroup is disabled. ChoiceCards has no disabled state. |
Behavior#
ChoiceCardmust be a child ofRadioGroup, which ownsvalue,onValueChangeand the keyboard. Each card needs a uniqueid; it links the label to the radio.ChoiceCardsis controlled withvalueandonValueChangeand needs alabelfor 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.
ChoiceCardslays out one column below 640px.columnssets the grid above that: 2 or 3 from 640px, 4 as two then four from 1024px, 5 as three then five.ChoiceCardsrenders buttons with radio roles, not native inputs, so it doesn't submit with a native form.
Do and don't#
ChoiceCards on config pages, ChoiceCard in campaign steps.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#
ChoiceCarduses Base UI Radio: each card is a<label>for arole="radio". Name theRadioGroupwitharia-labelledbypointing at its heading.ChoiceCardsrendersrole="radiogroup"witharia-labelfromlabel, and each card is a<button role="radio">witharia-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.
| Keys | Action |
|---|---|
| Tab | Moves 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. |
| Space | Selects the focused card. |
Design tokens#
| Token | Used for |
|---|---|
--primary | Checked ring or border, tint and radio |
--card | Card fill |
shadow-border | ChoiceCards edge at rest |
shadow-border-hover | ChoiceCards edge on hover |
--border | ChoiceCard edge |
--muted | ChoiceCard hover fill at 50% |
--input | Unchecked radio ring |
--ring | Focus ring |
--muted-foreground | Description and icon |
--radius-xl | 12px 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.
| Prop | Type | Default | Description |
|---|---|---|---|
idRequired | string | No default | Unique id that links the card's label to its radio. |
valueRequired | string | No default | The value the group reports when this card is chosen. |
titleRequired | ReactNode | No default | The option's name. |
description | ReactNode | No default | One sentence on what the option does. |
leading | ReactNode | No default | Rendered between the radio and the title, such as an icon tile. |
trailing | ReactNode | No default | Rendered 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.
| Prop | Type | Default | Description |
|---|---|---|---|
valueRequired | V | No default | The chosen option's value. |
onValueChangeRequired | (value: V) => void | No default | Called on click and arrow keys. |
optionsRequired | { value: V; label: string; description?: ReactNode; icon?: ReactNode }[] | No default | The cards, in order. |
labelRequired | string | No default | The radio group's accessible name. |
columns | 2 | 3 | 4 | 5 | 2 | Grid columns from 640px up. Always one column below. |
className | string | No default | Merged 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.