# Choice Card Group (/docs/choice-card-group)





`ChoiceCardGroup` is the richer cousin of [`RadioGroup`](/docs/radio-group) and [`Checkbox`](/docs/checkbox): instead of a bare control beside a label, each option is a bordered **card** with a title, an optional helper line, and an optional leading icon or avatar. Selecting a card tints its border and fill with the active palette. One prop — `multiple` — switches between single-choice (radio) and multi-choice (checkbox). It implements the kit's value-based form-control contract, so `name` submits and `onChange` hands you the value directly; label, description, and error are built in via [`Field`](/docs/field).

```tsx
import { ChoiceCardGroup } from '@42-central/ui-react/choice-card-group';

<ChoiceCardGroup
  label="Next project"
  defaultValue="minishell"
  data={[
    { value: 'philosophers', label: 'Philosophers', description: 'Threads and mutexes.' },
    { value: 'minishell', label: 'Minishell', description: 'Parsing, pipes, builtins.' },
  ]}
/>
```

## Playground [#playground]

<story.Playground />

## Single vs. multi [#single-vs-multi]

By default the group is **single-choice**: it rides Ark UI's `RadioGroup`, the value is a `string | null`, and each card shows a radio dot. Pass `multiple` to make it **multi-choice**: it rides `CheckboxGroup`, the value becomes a `string[]`, and each card shows a checkbox tick. `onChange` hands you the next value directly in both modes.

<Preview align="block" code="&#x22;// single — value is a string | null\n<ChoiceCardGroup\n  label=\&#x22;Next project\&#x22;\n  defaultValue=\&#x22;minishell\&#x22;\n  data={[\n    { value: 'philosophers', label: 'Philosophers', description: 'Threads and mutexes.' },\n    { value: 'minishell', label: 'Minishell', description: 'Parsing, pipes, builtins.' },\n    { value: 'cub3d', label: 'Cub3D', description: 'Raycasting, Wolfenstein-style.' },\n  ]}\n/>\n\n// multi — value is a string[]\n<ChoiceCardGroup\n  multiple\n  label=\&#x22;Notifications\&#x22;\n  defaultValue={['email']}\n  data={[\n    { value: 'email', label: 'Email', description: 'Evaluation summaries in your inbox.' },\n    { value: 'sms', label: 'SMS', description: 'Texts when a correction slot opens up.' },\n    { value: 'push', label: 'Push', description: 'Cluster and defense reminders.' },\n  ]}\n/>&#x22;">
  <div className="grid w-full gap-6 sm:grid-cols-2">
    <ChoiceCardGroup
      label="Next project"
      defaultValue="minishell"
      data="[
      { value: 'philosophers', label: 'Philosophers', description: 'Threads and mutexes.' },
      { value: 'minishell', label: 'Minishell', description: 'Parsing, pipes, builtins.' },
      { value: 'cub3d', label: 'Cub3D', description: 'Raycasting, Wolfenstein-style.' },
    ]"
    />

    <ChoiceCardGroup
      label="Notifications"
      defaultValue="['email']"
      data="[
      { value: 'email', label: 'Email', description: 'Evaluation summaries in your inbox.' },
      { value: 'sms', label: 'SMS', description: 'Texts when a correction slot opens up.' },
      { value: 'push', label: 'Push', description: 'Cluster and defense reminders.' },
    ]"
    />
  </div>
</Preview>

## Items [#items]

`data` accepts bare strings (where the value *is* the label) or `{ value, label, description?, icon?, disabled? }` objects — mixed freely.

```tsx
// 1 — strings (value === label)
<ChoiceCardGroup data={['Common Core', 'Piscine']} />

// 2 — objects, with optional per-item description + disabled
<ChoiceCardGroup
  data={[
    { value: 'morning', label: 'Morning', description: 'Before the cluster fills up.' },
    { value: 'afternoon', label: 'Afternoon', description: 'Peak cluster hours.' },
    { value: 'evening', label: 'Evening', disabled: true },
  ]}
/>
```

Each card also accepts an optional `testId`, defaulting to `` `item-${value}` `` and rendered on that card's own clickable root — see the [Testing](/docs/testing) guide.

<story.Preview variant="Disabled item" />

## Leading icon [#leading-icon]

Give an item an `icon` — any node, so an icon or an avatar — and that card lays it at the **start** and pushes the selection indicator to the **end**. Cards without an icon keep the indicator at the start.

<Preview align="block" code="&#x22;import { Cpu, Terminal, Box } from 'lucide-react';\n\n<ChoiceCardGroup\n  label=\&#x22;Next project\&#x22;\n  defaultValue=\&#x22;minishell\&#x22;\n  data={[\n    { value: 'philosophers', label: 'Philosophers', description: 'Threads and mutexes.', icon: <Cpu /> },\n    { value: 'minishell', label: 'Minishell', description: 'Parsing, pipes, builtins.', icon: <Terminal /> },\n    { value: 'cub3d', label: 'Cub3D', description: 'Raycasting, Wolfenstein-style.', icon: <Box /> },\n  ]}\n/>&#x22;">
  <div className="grid w-full gap-6 sm:grid-cols-2">
    <ChoiceCardGroup
      label="Next project"
      defaultValue="minishell"
      data="[
      { value: 'philosophers', label: 'Philosophers', description: 'Threads and mutexes.', icon: <Cpu /> },
      { value: 'minishell', label: 'Minishell', description: 'Parsing, pipes, builtins.', icon: <Terminal /> },
      { value: 'cub3d', label: 'Cub3D', description: 'Raycasting, Wolfenstein-style.', icon: <Box /> },
    ]"
    />

    <ChoiceCardGroup
      color="blue"
      variant="filled"
      label="Notifications"
      defaultValue="['email', 'push']"
      data="[
      { value: 'email', label: 'Email', icon: <Mail /> },
      { value: 'sms', label: 'SMS', icon: <MessageSquare /> },
      { value: 'push', label: 'Push', icon: <Bell /> },
    ]"
    />
  </div>
</Preview>

## Variants [#variants]

The indicator (the tick / dot) reuses the shared [`Checkbox`](/docs/checkbox) surface, so it has the same two `variant`s: `default` is a monochrome mark; `filled` paints the checked mark with the active `color`. The selected card follows the same split: `default` gets a strong neutral border, while `filled` gets a translucent palette border and a half-strength soft tint — deliberately quiet, so a checked card in a loud palette (an `orange` warning flag, say) doesn't overpower the rest of a form. The colored mark is what carries the palette.

<Preview align="block" code="&#x22;<ChoiceCardGroup variant=\&#x22;default\&#x22; color=\&#x22;brand\&#x22; defaultValue=\&#x22;push-swap\&#x22; data={[...]} />\n<ChoiceCardGroup variant=\&#x22;filled\&#x22; color=\&#x22;brand\&#x22; defaultValue=\&#x22;push-swap\&#x22; data={[...]} />&#x22;">
  <div className="grid w-full gap-6 sm:grid-cols-2">
    <ChoiceCardGroup
      variant="default"
      color="brand"
      defaultValue="push-swap"
      data="[
      { value: 'push-swap', label: 'Push Swap', description: 'Monochrome mark.' },
      { value: 'pipex', label: 'Pipex', description: 'Neutral at rest.' },
    ]"
    />

    <ChoiceCardGroup
      variant="filled"
      color="brand"
      defaultValue="push-swap"
      data="[
      { value: 'push-swap', label: 'Push Swap', description: 'Palette-colored mark.' },
      { value: 'pipex', label: 'Pipex', description: 'Tinted when checked.' },
    ]"
    />
  </div>
</Preview>

## Sizes [#sizes]

Five sizes — `xs` through `xl` — matching the rest of the family. The card padding, text, icon, and indicator all scale together.

<Preview align="block" code="&#x22;<ChoiceCardGroup size=\&#x22;sm\&#x22; data={['Common Core', 'Piscine']} defaultValue=\&#x22;Common Core\&#x22; />\n<ChoiceCardGroup size=\&#x22;md\&#x22; data={['Common Core', 'Piscine']} defaultValue=\&#x22;Common Core\&#x22; />\n<ChoiceCardGroup size=\&#x22;lg\&#x22; data={['Common Core', 'Piscine']} defaultValue=\&#x22;Common Core\&#x22; />&#x22;">
  <div className="grid w-full gap-6 sm:grid-cols-3">
    <ChoiceCardGroup size="sm" data="['Common Core', 'Piscine']" defaultValue="Common Core" />

    <ChoiceCardGroup size="md" data="['Common Core', 'Piscine']" defaultValue="Common Core" />

    <ChoiceCardGroup size="lg" data="['Common Core', 'Piscine']" defaultValue="Common Core" />
  </div>
</Preview>

## Colors [#colors]

With `variant="filled"`, the selected card's border + tint and the indicator ride `color` through the `--c-*` slots — set it once and every kit palette tints for free.

<Preview align="block" code="&#x22;<ChoiceCardGroup color=\&#x22;neutral\&#x22; variant=\&#x22;filled\&#x22; defaultValue=\&#x22;a\&#x22; data={[...]} />\n<ChoiceCardGroup color=\&#x22;brand\&#x22; variant=\&#x22;filled\&#x22; defaultValue=\&#x22;a\&#x22; data={[...]} />\n<ChoiceCardGroup color=\&#x22;green\&#x22; variant=\&#x22;filled\&#x22; defaultValue=\&#x22;a\&#x22; data={[...]} />\n<ChoiceCardGroup color=\&#x22;orange\&#x22; variant=\&#x22;filled\&#x22; defaultValue=\&#x22;a\&#x22; data={[...]} />\n<ChoiceCardGroup color=\&#x22;red\&#x22; variant=\&#x22;filled\&#x22; defaultValue=\&#x22;a\&#x22; data={[...]} />&#x22;">
  <div className="grid w-full gap-3 sm:grid-cols-5">
    <ChoiceCardGroup color="neutral" variant="filled" defaultValue="a" data="[{ value: 'a', label: 'Neutral' }, { value: 'b', label: 'Other' }]" />

    <ChoiceCardGroup color="brand" variant="filled" defaultValue="a" data="[{ value: 'a', label: 'Brand' }, { value: 'b', label: 'Other' }]" />

    <ChoiceCardGroup color="green" variant="filled" defaultValue="a" data="[{ value: 'a', label: 'Green' }, { value: 'b', label: 'Other' }]" />

    <ChoiceCardGroup color="orange" variant="filled" defaultValue="a" data="[{ value: 'a', label: 'Orange' }, { value: 'b', label: 'Other' }]" />

    <ChoiceCardGroup color="red" variant="filled" defaultValue="a" data="[{ value: 'a', label: 'Red' }, { value: 'b', label: 'Other' }]" />
  </div>
</Preview>

## Custom rendering [#custom-rendering]

By default each card renders its `label` and `description`. Pass `renderItem` to fill the text block yourself — it receives the (normalized) `item` and the live `{ checked, disabled }` state, and keeps the card's surface, icon slot, indicator, and selection wiring. Define it in a `"use client"` module so it survives the server → client boundary.

```tsx
'use client';

<ChoiceCardGroup
  multiple
  data={bonuses}
  renderItem={(item, { checked }) => (
    <span className="flex items-center justify-between gap-2">
      <span className="font-medium">{item.label}</span>
      <span className={checked ? 'text-(--c-text)' : 'text-gray-light-500'}>{item.description}</span>
    </span>
  )}
/>
```

## Label, description & error [#label-description--error]

`ChoiceCardGroup` wraps itself in [`Field`](/docs/field), so `label` / `description` / `error` / `required` work out of the box and wire the `aria-*` relationships. Passing `error` flips the group to the invalid (red) state — independently settable via `invalid` for form libraries.

<story.Preview variant="Description & error" />

## Controlled [#controlled]

Drive the value from state. `onChange` hands you the selected value directly — a `string | null` (single) or a `string[]` (multi).

```tsx
'use client';
import { useState } from 'react';
import { ChoiceCardGroup } from '@42-central/ui-react/choice-card-group';

export function BonusPicker() {
  const [bonuses, setBonuses] = useState<string[]>([]);
  return (
    <ChoiceCardGroup
      multiple
      label="Bonuses"
      value={bonuses}
      onChange={setBonuses}
      data={['Bonus part', 'Extra flags', 'Unit tests']}
    />
  );
}
```

## Forms [#forms]

Each card is backed by a hidden native input sharing the field `name` — a radio per card in single mode, a checkbox per card in multi mode — so the selection rides a native `<form>` submission and `FormData`, no controlled state required.

```tsx
<form action={save}>
  <ChoiceCardGroup multiple name="bonuses" data={['Bonus part', 'Extra flags']} />
</form>
```

## API [#api]

<TypeTable
  type="{
  data: {
    type: 'string[] | { value, label, description?, icon?, disabled? }[]',
    description:
      'The cards. Bare strings (value === label) or objects. icon is the leading slot (any node) and moves the indicator to the card end.',
  },
  multiple: {
    type: 'boolean',
    default: 'false',
    description: 'Multi-select (checkbox, value string[]) when set; single-select (radio, value string | null) otherwise.',
  },
  value: {
    type: 'string | null | string[]',
    description: 'Controlled value. string | null when single, string[] when multiple.',
  },
  defaultValue: {
    type: 'string | null | string[]',
    description: 'Uncontrolled initial value (matches value by mode).',
  },
  onChange: {
    type: '(value: string | null | string[]) => void',
    description: 'Fires with the next value — a string | null when single, a string[] when multiple.',
  },
  renderItem: {
    type: '(item, state) => ReactNode',
    description: 'Fill each card\'s text block yourself. state is { checked, disabled }. Define it in a &#x22;use client&#x22; module.',
  },
  orientation: {
    type: '&#x22;vertical&#x22; | &#x22;horizontal&#x22;',
    default: '&#x22;vertical&#x22;',
    description: 'Stack the cards in a column or a wrapping row.',
  },
  variant: {
    type: '&#x22;default&#x22; | &#x22;filled&#x22;',
    default: '&#x22;default&#x22;',
    description: 'Indicator mark: monochrome (default) or palette-colored (filled).',
  },
  size: { type: '&#x22;xs&#x22; | &#x22;sm&#x22; | &#x22;md&#x22; | &#x22;lg&#x22; | &#x22;xl&#x22;', default: '&#x22;md&#x22;', description: 'Card sizing.' },
  color: { type: 'Color', default: '&#x22;brand&#x22;', description: 'Selected-card tint + filled indicator.' },
  invalid: { type: 'boolean', default: 'false', description: &#x22;Red invalid state, OR'd with error.&#x22; },
  disabled: { type: 'boolean', default: 'false', description: 'Disable the whole group.' },
  readOnly: { type: 'boolean', default: 'false', description: 'Keep contrast but reject changes.' },
  label: { type: 'ReactNode', description: 'Field label.' },
  description: { type: 'ReactNode', description: 'Field helper text.' },
  error: { type: 'ReactNode', description: 'Field error; sets the invalid state.' },
  required: { type: 'boolean', default: 'false', description: 'Required flag + asterisk.' },
  name: { type: 'string', description: 'Name for the hidden inputs (form submission).' },
  classNames: { type: '{ field?, root?, item?, control?, label?, description? }', description: 'Per-part class overrides.' },
  testId: {
    type: 'string',
    description: 'data-testid on the group root (the underlying RadioGroup or CheckboxGroup, depending on multiple). See the per-item testId note in Items above for the cards themselves.',
  },
}"
/>

## Accessibility [#accessibility]

* Built on Ark UI's `RadioGroup` (single) / `CheckboxGroup` (multi) machines: each card is a clickable `<label>` wrapping a hidden native input, so the whole card toggles and exposes the right role (`radio` / `checkbox`) and `aria-checked`.
* `←` / `→` / `↑` / `↓` move focus between cards in both modes. In single mode selection follows focus (native radio behavior); in multi mode `Space` (or a click) toggles the focused card. The keyboard focus ring is drawn on the whole card.
* `invalid` (or `error`) flips the group to the red state; label / description / error associations are wired via the built-in `Field`.
* A leading `icon` is decorative (`aria-hidden`); the card's `label` carries the accessible name.
* RTL is honored via the document `dir` — the icon, text, and indicator mirror automatically (logical `start` / `end`).
