Skip to the content

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", a role="radiogroup" of role="radio" buttons; with type="multiple", a role="group" of role="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-label or aria-labelledby. Each control is named by its card's title (aria-labelledby) and described by its aside and description (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 --ring outline 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

Keyboard
KeyBehaviour
TabMoves 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.
SpaceChooses the focused radio card, or toggles the focused checkbox card.

API

ChoiceCardGroup

ChoiceCardGroup props
PropTypeDefaultDescription
typerequired"single" | "multiple"single: one card is chosen, as radios. multiple: any number of cards are chosen, as checkboxes.
defaultValuestring | string[]The chosen card's value at the start, when it controls itself. The chosen cards' values at the start, when it controls itself.
disabledbooleanDisables every card.
formstringThe id of the form the value belongs to, for a group placed outside it.
namestringThe 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.
requiredbooleanMakes 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.
valuestring | 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.

ChoiceCard props
PropTypeDefaultDescription
titlerequiredReactNodeThe card's name: it names the radio or checkbox.
valuerequiredstringThe value the group holds while this card is chosen.
asideReactNodeShort text at the end of the title row, such as a price. Read with the description.
descriptionReactNodeA line or two under the title, read as the control's description.
disabledbooleanfalseDisables this card only.
iconReactNodeAn 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.

Theme tokens
TokenUsed for
--accentbackground
--foregroundtext
--invalidborder
--muted-foregroundtext
--primaryborder
--ringoutline