|Kit

Choice Card Group

A data-driven set of selectable cards — radio (single) or checkbox (multi) — each a bordered card with a title, helper line, and an optional leading icon, with built-in label / description / error and native form submission.

ChoiceCardGroup is the richer cousin of RadioGroup and 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.

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

Component

Loading preview

Props

Code

<Component
  orientation="vertical"
  size="md"
  label="Next project"
/>

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.

Code

// single — value is a string | null
<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.' },
  ]}
/>

// multi — value is a string[]
<ChoiceCardGroup
  multiple
  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.' },
  ]}
/>

Items

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

// 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 guide.

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.

Code

import { Cpu, Terminal, Box } from 'lucide-react';

<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 /> },
  ]}
/>

Variants

The indicator (the tick / dot) reuses the shared Checkbox surface, so it has the same two variants: 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.

Code

<ChoiceCardGroup variant="default" color="brand" defaultValue="push-swap" data={[...]} />
<ChoiceCardGroup variant="filled" color="brand" defaultValue="push-swap" data={[...]} />

Sizes

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

Code

<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" />

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.

Code

<ChoiceCardGroup color="neutral" variant="filled" defaultValue="a" data={[...]} />
<ChoiceCardGroup color="brand" variant="filled" defaultValue="a" data={[...]} />
<ChoiceCardGroup color="green" variant="filled" defaultValue="a" data={[...]} />
<ChoiceCardGroup color="orange" variant="filled" defaultValue="a" data={[...]} />
<ChoiceCardGroup color="red" variant="filled" defaultValue="a" data={[...]} />

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.

'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

ChoiceCardGroup wraps itself in 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.

You can change this anytime.
Choose a project to continue

Controlled

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

'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

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.

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

API

Prop

Type

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).

Changelog

v0.19.0
  • These entry points ship "use client", so a Server Component can import them

    #320
v0.17.0
  • Soften the filled variant's selected card

    #271
  • Centre the indicator on the title's first line

    #270
v0.11.0
  • The focus ring narrows from 3px to 2px, matching the rest of the kit

    #161
v0.6.0
  • The description sits above the control, and the error shows independently of it

    #101
v0.5.0
  • value and onChange narrow to the literal values of as const data, like Select

    #71
  • Multiple-choice cards move focus with the arrow keys, and the group uses the kit's translucent surface

    #65
v0.3.1
  • Correct dark-mode border shades on Kbd, Notification, FileUpload and ChoiceCardGroup

    #37
v0.3.0
  • items → data on RadioGroup, ChoiceCardGroup, SegmentGroup and Menu

    #34
v0.0.1
  • ChoiceCardGroup: selectable cards with radio or checkbox semantics

On this page