# 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"`
- **Radix Radio Group:** <https://www.radix-ui.com/primitives/docs/components/radio-group>
- **APG Radio Group:** <https://www.w3.org/WAI/ARIA/apg/patterns/radio/>
- **Page:** <https://ui.booleanpress.com/components/choice-card> · @booleanpress/ui 0.2.0

## Usage

Cards suit a choice whose options each need a sentence: a plan, a connection type, a notification. `type="single"` makes a [radio group](/components/radio-group); `type="multiple"` a [checkbox group](/components/checkbox-group).

```tsx
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.

```tsx
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.

```tsx
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.

```tsx
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.

```tsx
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`.

```tsx
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

| 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 |
| --- | --- | --- | --- |
| `type` (required) | `"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 |
| --- | --- | --- | --- |
| `title` (required) | `ReactNode` |  | The card's name: it names the radio or checkbox. |
| `value` (required) | `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`.

| Token | Used for |
| --- | --- |
| `--accent` | background |
| `--foreground` | text |
| `--invalid` | border |
| `--muted-foreground` | text |
| `--primary` | border |
| `--ring` | outline |
