# Choice card group

A choice between a few options that each need more than a word: a plan, a model, a way to run something. Each option is a card with a title, a description and, if it helps, a detail such as a price or a limit; the group is one radio group, so arrow keys move between the cards and choose (following the reading direction), and it is one tab stop.

Nothing is chosen until the person chooses: leave out `defaultValue`, and pass `value={null}` when you control it, because a card already chosen nudges people to accept it without reading the others. A card that can't be chosen takes `locked` with the reason, and stays in the arrow-key order so the reason can be read.

The cards sit in up to `maxAcross` columns, as many as the group's own width allows (a container query, so a group in a narrow panel shows one column whatever the window's width). Name the group with `label` or `aria-labelledby`.

- Registry name: `choice-card-group` (`@candor/choice-card-group`)
- Tier: Pro. Installing it needs a Pro, Premium or Team licence key: the person runs `npx candor-ui key <key>` themselves; never ask for the key in chat or write it to a file.
- Docs: https://candor.themethodic.com/docs/choice-card-group

## Install

```bash
npx candor-ui add choice-card-group
```

Run `npx candor-ui@latest init` once first (https://candor.themethodic.com/get-started.md). Without the CLI: `npx shadcn@latest add @candor/choice-card-group`.
It brings in: locale, locked, tokens, utils.
npm packages: @base-ui/react@^1.9.0, lucide-react@^1.67.0.

## Example

```tsx
"use client"

import * as React from "react"
import { FileText, MessageSquareMore, Send } from "lucide-react"
import { ChoiceCard, ChoiceCardGroup } from "@/components/candor/ui/choice-card-group"
import { TooltipProvider } from "@/components/candor/ui/tooltip"

// Nothing is chosen until the person chooses: no defaultValue, and null while the app holds the value. A card already
// chosen would nudge people to accept it without reading the others. A card that can't be chosen says why.
export default function Example() {
  const [mode, setMode] = React.useState<string | null>(null)
  return (
    <TooltipProvider>
      <div className="flex w-full max-w-3xl flex-col gap-3">
        <ChoiceCardGroup label="How should the agent reply to new tickets?" value={mode} onValueChange={setMode}>
          <ChoiceCard value="draft" icon={FileText} title="Draft only" description="It writes a reply and leaves it for you to send." />
          <ChoiceCard
            value="ask"
            icon={MessageSquareMore}
            title="Draft and ask"
            description="It writes a reply and asks you before sending it."
            detail="Up to 50 tickets a day"
          />
          <ChoiceCard
            value="send"
            icon={Send}
            title="Send on its own"
            description="It replies without asking."
            locked="Your workspace's policy requires a person to approve replies"
          />
        </ChoiceCardGroup>
        <a href="#plans" className="text-sm text-accent-text underline-offset-4 hover:underline">
          Compare what each mode can do
        </a>
      </div>
    </TooltipProvider>
  )
}
```

## <ChoiceCardGroup>

Props:

- `value?: string | null | undefined`: The chosen card's value, when the app controls it; `null` while nothing is chosen.
- `defaultValue?: string | undefined`: The card chosen at first, when the group keeps its own state. Leave it out so nothing is chosen for the person.
- `onValueChange?: ((value: string) => void) | undefined`: Called with the value of the card the person chooses.
- `label?: string | undefined`: The group's name, shown above the cards and read as the radio group's name. Give this or `aria-labelledby`.
- `"aria-labelledby"?: string | undefined`: The id of an element elsewhere that names the group, in place of `label`.
- `maxAcross?: 1 | 2 | 3 | 4 | undefined` (default `3`): The most cards side by side, when the group is wide enough. Defaults to 3.
- `name?: string | undefined`: The form field's name: the chosen card's value is submitted under it.
- `required?: boolean | undefined`: A card must be chosen before the form is submitted.
- `className?: string | undefined`: Classes for the group's outer box.
- `children: React.ReactNode`: The `ChoiceCard`s.

## <ChoiceCard>

One option in a `ChoiceCardGroup`: a card that is a single radio. Its name is its `title`; its `description` and `detail` describe it. Everything on the card is read as part of that one radio, so put nothing that can be pressed inside it: a link to compare plans goes beside the group.

A chosen card has a thicker border and a check mark, not a colour alone, and both are drawn as borders and strokes, so they stay visible in forced colours.

Props:

- `value: string`: The value the group reports while this card is chosen.
- `title: string`: The option's name, shown at the top of the card and read as the radio's name.
- `description?: React.ReactNode | undefined`: What choosing it means, in a sentence or two. Read after the name.
- `detail?: React.ReactNode | undefined`: A line under the description, such as a price, a limit or a figure. Text and figures only: it is part of the radio, so nothing in it can be pressed. Read after the description.
- `icon?: LucideIcon | undefined`: An icon beside the title, drawn for the eye only.
- `locked?: string | null | undefined`: Why this card can't be chosen right now. It stays in the arrow-key order, is marked `aria-disabled` and says why (see `Locked`).
- `className?: string | undefined`: Classes, merged with Candor's.

## Motion

- **tint** (tint): A card's border changes colour when the person chooses it, and a ring fades in when it takes keyboard focus. A card already chosen when the group appears is drawn chosen at once.

Set with `--cd-motion-choice-card-group-<moment>-*` in your brand file; the families are in the design reference.

## Rules for using Candor UI

- Components take plain typed data. Never pass model output as HTML or render it as a link; messages are typed blocks.
- A control that can't be used says why: wrap it in `<Locked reason="…">`, don't just disable it.
- Don't show a value you don't have: leave it out (it renders as —) rather than guess. An estimate is labelled as one.
- Nothing is pre-selected in a decision a person makes, and the agent is named from your app's data, never from model output.
- Your app's words go in `LocaleProvider`: its fixed strings in `strings`, its nouns in `nouns`; entity kinds and agent roles in `EntityKindsProvider` and `AgentRolesProvider`.
