ComponentsForm
Choice card
Options drawn as cards with a title and a description, chosen one at a time or several at once.
Import
import { ChoiceCardGroup, ChoiceCard } from "@booleanpress/ui/choice-card"Usage
Cards suit a choice whose options each need a sentence: a plan, a connection type, a notification. type="single" makes a radio group; type="multiple" a checkbox group.
import { ChoiceCard, ChoiceCardGroup } from "@booleanpress/ui/choice-card"
export function Plan() {
return (
<ChoiceCardGroup type="single" defaultValue="pro" aria-label="Plan">
<ChoiceCard value="starter" title="Starter" description="For one site sending its own receipts." />
<ChoiceCard value="pro" title="Pro" description="For teams sending from several sites." />
</ChoiceCardGroup>
)
}With type="single" the value is a string, with type="multiple" an array of strings; either is uncontrolled with defaultValue or controlled with value and onValueChange. Each ChoiceCard takes a value, a title that names its control, and an optional description, icon and aside (short text at the end of the title row, such as a price), read as the control's description. The whole card is the control's label, so a click anywhere on it chooses it. The radio sits at the end and the checkbox at the start, as indicator can change. The group is a one-column grid; give it className (sm:grid-cols-3) for a row. size and variant reach the radios or checkboxes; disabled disables every card, or one card with its own disabled; aria-invalid gives every card the error edge. With name, the choice is submitted in a form.
Examples
Single
Plans as radio cards: a title with a badge, a price at the end of the title row and a description.
import { Badge } from "@booleanpress/ui/badge"
import { ChoiceCard, ChoiceCardGroup } from "@booleanpress/ui/choice-card"
export default function ChoiceCardSingle() {
return (
<div className="flex w-full max-w-xs flex-col gap-2">
<span id="plan-label" className="text-base/normal font-medium text-foreground">
Choose a plan:
</span>
<ChoiceCardGroup type="single" defaultValue="pro" aria-labelledby="plan-label">
<ChoiceCard value="starter" title="Starter" aside={<Price amount="$0" />} description="For one site sending its own receipts." />
<ChoiceCard
value="pro"
title={
<>
Pro <Badge>Popular</Badge>
</>
}
aside={<Price amount="$29" />}
description="For teams sending from several sites."
/>
<ChoiceCard value="agency" title="Agency" aside={<span className="font-semibold">Custom</span>} description="For agencies running mail for their clients." />
</ChoiceCardGroup>
</div>
)
}
function Price({ amount }: { amount: string }) {
return (
<>
<span className="font-semibold">{amount}</span>
<span className="text-muted-foreground">/month</span>
</>
)
}Multiple
type="multiple" makes checkbox cards, three to a row from the small breakpoint.
import { ChoiceCard, ChoiceCardGroup } from "@booleanpress/ui/choice-card"
export default function ChoiceCardMultiple() {
return (
<div className="flex w-full max-w-3xl flex-col gap-4">
<span id="notify-label" className="text-base/normal font-medium text-foreground">
Notify me about:
</span>
<ChoiceCardGroup type="multiple" defaultValue={["bounces"]} aria-labelledby="notify-label" className="sm:grid-cols-3">
<ChoiceCard value="bounces" title="Bounces" description="A message could not be delivered and was returned." />
<ChoiceCard value="complaints" title="Complaints" description="A recipient marked a message as spam." />
<ChoiceCard value="digest" title="Weekly digest" description="A summary of sends, opens and clicks each Monday." />
</ChoiceCardGroup>
</div>
)
}With icons
icon puts a decorative icon before each title.
import { CloudIcon, MailIcon, ServerIcon } from "lucide-react"
import { ChoiceCard, ChoiceCardGroup } from "@booleanpress/ui/choice-card"
export default function ChoiceCardWithIcons() {
return (
<ChoiceCardGroup type="single" defaultValue="api" aria-label="Connection type" className="w-full max-w-xs">
<ChoiceCard value="api" icon={<CloudIcon />} title="API" description="Send through the provider's HTTP API." />
<ChoiceCard value="smtp" icon={<ServerIcon />} title="SMTP" description="Send through any SMTP server." />
<ChoiceCard value="php" icon={<MailIcon />} title="PHP mail" description="Send with the server's own mail function." />
</ChoiceCardGroup>
)
}Disabled option
disabled on one card dims it and takes it out of the choice.
import { ChoiceCard, ChoiceCardGroup } from "@booleanpress/ui/choice-card"
export default function ChoiceCardDisabled() {
return (
<ChoiceCardGroup type="single" defaultValue="daily" aria-label="Log retention" className="w-full max-w-xs">
<ChoiceCard value="daily" title="30 days" description="Logs older than a month are removed." />
<ChoiceCard value="quarter" title="90 days" description="Logs older than three months are removed." />
<ChoiceCard value="forever" title="Keep forever" description="Available on the Agency plan." disabled />
</ChoiceCardGroup>
)
}Invalid
aria-invalid draws every card's edge in --invalid, with the message in aria-describedby.
import { ChoiceCard, ChoiceCardGroup } from "@booleanpress/ui/choice-card"
export default function ChoiceCardInvalid() {
return (
<div className="flex w-full max-w-xs flex-col gap-2">
<ChoiceCardGroup type="single" aria-label="Sending domain" aria-invalid aria-describedby="domain-error">
<ChoiceCard value="mail" title="mail.example.com" description="Verified on 2 October 2026." />
<ChoiceCard value="news" title="news.example.com" description="Verified on 14 October 2026." />
</ChoiceCardGroup>
<p id="domain-error" className="text-sm/normal text-destructive-strong">
Choose the domain to send from.
</p>
</div>
)
}Accessibility
- Semantics
- With
type="single", arole="radiogroup"ofrole="radio"buttons; withtype="multiple", arole="group"ofrole="checkbox"buttons. Each card is a<label>for its control, so it adds to the control's target without adding a role. - Labels
- Name the group with
aria-labeloraria-labelledby. Each control is named by its card'stitle(aria-labelledby) and described by itsasideanddescription(aria-describedby); the icon is hidden from screen readers. - Focus
- Radio cards are one tab stop, on the chosen card; checkbox cards are a tab stop each. The card with keyboard focus has the 1px
--ringoutline 2px round it, in place of the control's own. - Known limits
- Keep links and buttons out of a card: the whole card is a label, and a control inside it would compete with the card's own.
Keyboard
| Key | Behaviour |
|---|---|
| Tab | Moves to the chosen radio card (or the first), or to the next checkbox card. |
| โorโ | Single: chooses the next card, from the last back to the first (โ goes the other way in right-to-left pages). |
| โorโ | Single: chooses the previous card, from the first round to the last. |
| Space | Chooses the focused radio card, or toggles the focused checkbox card. |
API
ChoiceCardGroup
| Prop | Type | Default | Description |
|---|---|---|---|
typerequired | "single" | "multiple" | single: one card is chosen, as radios.
multiple: any number of cards are chosen, as checkboxes. | |
defaultValue | string | string[] | The chosen card's value at the start, when it controls itself. The chosen cards' values at the start, when it controls itself. | |
disabled | boolean | Disables every card. | |
form | string | The id of the form the value belongs to, for a group placed outside it. | |
name | string | The form field name; the chosen card's value (each chosen card's, with multiple) is submitted under it. | |
onValueChange | ((value: string) => void) | ((value: string[]) => void) | Called with the chosen card's value. Called with the chosen cards' values. | |
required | boolean | Makes a choice required before the form can be submitted (single only). | |
size | "default" | "sm" | "lg" | The size of the radios or checkboxes. Defaults to the provider's controlSize. | |
value | string | string[] | The chosen card's value, when you control it. The chosen cards' values, when you control them. | |
variant | "default" | "filled" | The look of the radios or checkboxes. Defaults to the provider's fieldVariant. |
ChoiceCard
Renders a label and passes it every other prop.
| Prop | Type | Default | Description |
|---|---|---|---|
titlerequired | ReactNode | The card's name: it names the radio or checkbox. | |
valuerequired | string | The value the group holds while this card is chosen. | |
aside | ReactNode | Short text at the end of the title row, such as a price. Read with the description. | |
description | ReactNode | A line or two under the title, read as the control's description. | |
disabled | boolean | false | Disables this card only. |
icon | ReactNode | An icon before the title. Decorative. | |
indicator | "start" | "end" | Where the radio or checkbox sits: end for radios and start for checkboxes by default. |
Every part takes className, merged with its defaults by cn(), and ref, which reaches the element it renders.
Data attributes: data-slot="choice-card-group" (ChoiceCardGroup), data-slot="choice-card" (ChoiceCard), and data-disabled.
Theming
A card has no fill of its own, so it takes the page colour: a 1px --border edge and an 8px radius (6px for checkbox cards), --accent under the pointer and a --primary edge when chosen; aria-invalid makes the edge --invalid. The title is --foreground, the description --muted-foreground.
The tokens its classes read. Change their values in your theme, and it follows.
| Token | Used for |
|---|---|
--accent | background |
--foreground | text |
--invalid | border |
--muted-foreground | text |
--primary | border |
--ring | outline |