|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/ui-react/choice-card-group';

<ChoiceCardGroup
  label="Next project"
  defaultValue="minishell"
  items={[
    { 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<ChoiceCardGrouplabel="Next project"defaultValue="minishell"items={[  { 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[]<ChoiceCardGroupmultiplelabel="Notifications"defaultValue={['email']}items={[  { 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

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

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

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

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';<ChoiceCardGrouplabel="Next project"defaultValue="minishell"items={[  { 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 card's selected border + soft fill always follow the palette.

Code

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

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" items={['Common Core', 'Piscine']} defaultValue="Common Core" /><ChoiceCardGroup size="md" items={['Common Core', 'Piscine']} defaultValue="Common Core" /><ChoiceCardGroup size="lg" items={['Common Core', 'Piscine']} defaultValue="Common Core" />

Colors

The selected card's border + soft fill, and the filled indicator, ride color through the --c-* slots — set it once and every kit palette tints for free.

Code

<ChoiceCardGroup color="brand" variant="filled" defaultValue="a" items={[...]} /><ChoiceCardGroup color="green" variant="filled" defaultValue="a" items={[...]} /><ChoiceCardGroup color="purple" variant="filled" defaultValue="a" items={[...]} />

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
  items={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.

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/ui-react/choice-card-group';

export function BonusPicker() {
  const [bonuses, setBonuses] = useState<string[]>([]);
  return (
    <ChoiceCardGroup
      multiple
      label="Bonuses"
      value={bonuses}
      onChange={setBonuses}
      items={['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" items={['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.
  • In single mode, / / / move and select; selection follows focus. In multi mode, Tab moves between cards and Space toggles. 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).

On this page