# Radio Card Group

A group of cards where the user selects one option, each with a title and a description.

```tsx
import { Field, RadioCardGroup, type RadioCardOption } from 'ferry-ui'
import { Globe, Lock, Users } from 'lucide-react'

const OPTIONS: RadioCardOption[] = [
  { value: 'private', label: 'Private', description: 'Only you can open this project.', icon: <Lock /> },
  { value: 'team', label: 'Team', description: 'Each member of the workspace can open it.', icon: <Users /> },
  { value: 'public', label: 'Public', description: 'Each person with the link can read it.', icon: <Globe /> },
]

export default function RadioCardGroupHero() {
  return (
    <Field
      label="Visibility"
      labelAs="span"
      hint="You can change the visibility later."
      className="mx-auto w-full max-w-md"
    >
      <RadioCardGroup defaultValue="team" options={OPTIONS} />
    </Field>
  )
}
```

## Usage guidelines

- **For 2 to 6 options that need an explanation.** Each card has a title, and a description or an icon.
- **A plain option needs no card.** For options with only a label, use [Radio Group](/docs/components/radio-group).
- **A long list is a select.** Use [Select](/docs/components/select) for it.
- **Other choices have their own control.** Use [Switch](/docs/components/switch) for on or off, and [Checkbox](/docs/components/checkbox) for many selections.
- **The card is a button.** Do not put a button or a link in a card.

## Anatomy

Import the parts. Give the cards as data in `options`, or as `RadioCard` children.

```tsx title="Anatomy"

<RadioCardGroup options={[{ value: '', label: '', description: '' }]} />

<RadioCardGroup>
  <RadioCard value="" label="" description="" />
</RadioCardGroup>
```

The group must have a name. Put it in a [Field](/docs/components/field) with `labelAs="span"`, or pass `aria-label`.

## Examples

### Sizes and columns

The `size` prop is `lg` by default. Use `sm` for a choice in a form. `columns` sets 1, 2 or 3 columns. A phone shows one column.

```tsx
import { RadioCardGroup, type RadioCardOption } from 'ferry-ui'
import { Globe, Lock } from 'lucide-react'

const OPTIONS: RadioCardOption[] = [
  { value: 'private', label: 'Private', description: 'Only members can open it.', icon: <Lock /> },
  { value: 'public', label: 'Public', description: 'Each person with the link can read it.', icon: <Globe /> },
]

export default function RadioCardGroupSizes() {
  return (
    <div className="mx-auto flex w-full max-w-xl flex-col gap-6">
      <RadioCardGroup aria-label="Visibility, large cards" size="lg" columns={2} defaultValue="private" options={OPTIONS} />
      <RadioCardGroup aria-label="Visibility, small cards" size="sm" columns={2} defaultValue="private" options={OPTIONS} />
    </div>
  )
}
```

### Appearances

The `appearance` prop sets the look of the selected card. Use `soft` in a form that is in a card.

```tsx
import { RadioCardGroup, type RadioCardOption } from 'ferry-ui'
import { Globe, Lock } from 'lucide-react'

const OPTIONS: RadioCardOption[] = [
  { value: 'private', label: 'Private', description: 'Only members can open it.', icon: <Lock /> },
  { value: 'public', label: 'Public', description: 'Each person with the link can read it.', icon: <Globe /> },
]

export default function RadioCardGroupAppearances() {
  return (
    <div className="mx-auto flex w-full max-w-xl flex-col gap-6">
      <RadioCardGroup aria-label="Visibility, outline" appearance="outline" size="sm" columns={2} defaultValue="private" options={OPTIONS} />
      <RadioCardGroup aria-label="Visibility, soft" appearance="soft" size="sm" columns={2} defaultValue="private" options={OPTIONS} />
    </div>
  )
}
```

| Appearance | Selected card | Default indicator |
| --- | --- | --- |
| `outline` (default) | A primary outline. | `check` |
| `soft` | A soft primary fill. | `radio` |

### Indicators

The `indicator` prop sets the mark of the selected card.

```tsx
import { RadioCardGroup, type RadioCardOption } from 'ferry-ui'

const OPTIONS: RadioCardOption[] = [
  { value: 'monthly', label: 'Monthly', description: 'One invoice each month.' },
  { value: 'yearly', label: 'Yearly', description: 'One invoice each year.' },
]

export default function RadioCardGroupIndicators() {
  return (
    <div className="mx-auto flex w-full max-w-xl flex-col gap-6">
      <RadioCardGroup aria-label="Billing period, check mark" indicator="check" size="sm" columns={2} defaultValue="monthly" options={OPTIONS} />
      <RadioCardGroup aria-label="Billing period, radio mark" indicator="radio" size="sm" columns={2} defaultValue="monthly" options={OPTIONS} />
      <RadioCardGroup aria-label="Billing period, no mark" indicator="none" size="sm" columns={2} defaultValue="monthly" options={OPTIONS} />
    </div>
  )
}
```

| Indicator | Mark |
| --- | --- |
| `check` | A round check mark in the top right corner. |
| `radio` | A radio dot at the end of the row. |
| `none` | No mark. Only the border or the fill shows the selection. |

### Controlled state

Pass `value` and `onValueChange`. For a controlled group with no selection, pass `null`. With `undefined`, the group is uncontrolled.

```tsx
import * as React from 'react'
import { Button, RadioCardGroup, type RadioCardOption } from 'ferry-ui'

type Period = 'monthly' | 'yearly'

const OPTIONS: RadioCardOption<Period>[] = [
  { value: 'monthly', label: 'Monthly', description: 'One invoice each month.' },
  { value: 'yearly', label: 'Yearly', description: 'One invoice each year.' },
]

export default function RadioCardGroupControlled() {
  // `null`, not `undefined`: the group stays controlled while no card is selected.
  const [period, setPeriod] = React.useState<Period | null>(null)

  return (
    <div className="mx-auto flex w-full max-w-xl flex-col gap-3">
      <RadioCardGroup
        aria-label="Billing period"
        size="sm"
        columns={2}
        options={OPTIONS}
        value={period}
        onValueChange={setPeriod}
      />
      <div className="flex items-center justify-between gap-3">
        <span className="text-[13px] text-foreground-light">Selected: {period ?? 'none'}</span>
        <Button disabled={period === null} onClick={() => setPeriod(null)}>
          Clear
        </Button>
      </div>
    </div>
  )
}
```

### More content in a card

`RadioCard` shows its children below the description. Use them for a price or a [Badge](/docs/components/badge).

```tsx
import { Badge, RadioCard, RadioCardGroup } from 'ferry-ui'

const PLANS = [
  { value: 'free', label: 'Free', description: 'For one member and three projects.', price: '$0', popular: false },
  { value: 'pro', label: 'Pro', description: 'For a team, with no limit on projects.', price: '$19', popular: true },
  { value: 'team', label: 'Team', description: 'With roles and an audit log.', price: '$49', popular: false },
]

export default function RadioCardGroupChildren() {
  return (
    <RadioCardGroup aria-label="Plan" columns={3} defaultValue="pro" className="mx-auto w-full max-w-2xl">
      {PLANS.map((plan) => (
        <RadioCard key={plan.value} value={plan.value} label={plan.label} description={plan.description}>
          {/* Text and a badge only: the card is a button, so it cannot hold a button or a link. */}
          <span className="mt-1.5 flex flex-wrap items-center gap-2">
            <span className="text-[13px] font-medium text-foreground tabular">
              {plan.price}
              <span className="font-normal text-foreground-lighter"> / month</span>
            </span>
            {plan.popular && <Badge>Popular</Badge>}
          </span>
        </RadioCard>
      ))}
    </RadioCardGroup>
  )
}
```

### Preview

`media` puts a preview above the label. Use it to select a layout or an appearance.

```tsx
import { RadioCard, RadioCardGroup } from 'ferry-ui'

// A small drawing of a page layout, made with token classes.
function Preview({ sidebar = false }: { sidebar?: boolean }) {
  return (
    <span className="flex h-20 w-full gap-1.5 bg-surface-200 p-2">
      {sidebar && <span className="w-1/4 rounded-sm bg-surface-300" />}
      <span className="flex flex-1 flex-col gap-1.5">
        {!sidebar && <span className="h-2.5 rounded-sm bg-surface-300" />}
        <span className="flex-1 rounded-sm border bg-background" />
      </span>
    </span>
  )
}

export default function RadioCardGroupMedia() {
  return (
    <RadioCardGroup
      aria-label="Dashboard layout"
      indicator="radio"
      defaultValue="sidebar"
      className="mx-auto w-full max-w-md grid-cols-2"
    >
      <RadioCard
        value="sidebar"
        label="Sidebar"
        description="The navigation is on the left."
        media={<Preview sidebar />}
      />
      <RadioCard
        value="top-bar"
        label="Top bar"
        description="The navigation is above the content."
        media={<Preview />}
      />
    </RadioCardGroup>
  )
}
```

### Error

While a `Field` shows an `error`, it sets `aria-invalid` on the group. The cards that are not selected then get a red border.

```tsx
import * as React from 'react'
import { Button, Field, RadioCardGroup, toast, type RadioCardOption } from 'ferry-ui'

const PLANS: RadioCardOption[] = [
  { value: 'free', label: 'Free', description: 'For one member and three projects.' },
  { value: 'pro', label: 'Pro', description: 'For a team, with no limit on projects.' },
]

export default function RadioCardGroupInvalid() {
  const [plan, setPlan] = React.useState<string | null>(null)
  const [submitted, setSubmitted] = React.useState(false)

  function submit(event: React.FormEvent<HTMLFormElement>) {
    event.preventDefault()
    setSubmitted(true)
    if (plan !== null) toast.success('Plan selected')
  }

  return (
    <form noValidate className="mx-auto flex w-full max-w-xl flex-col gap-5" onSubmit={submit}>
      {/* `Field` names the group and sets `aria-invalid` on it while it shows the error. */}
      <Field
        label="Plan"
        labelAs="span"
        error={submitted && plan === null ? 'Select a plan to continue.' : undefined}
      >
        <RadioCardGroup size="sm" columns={2} options={PLANS} value={plan} onValueChange={setPlan} />
      </Field>
      <Button type="submit" variant="primary" className="self-end">
        Continue
      </Button>
    </form>
  )
}
```

## Accessibility

- The arrow keys move the focus and select a card. <Kbd>Tab</Kbd> enters and leaves the group.
- The `label` is the accessible name of a card. The `description` is its accessible description.
- The icon and the preview are decorative.

## API reference

Each part also accepts the props of its Radix UI primitive, the root and the item of RadioGroup.

### RadioCardGroup

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `value` | `T \| null` |  | Selected value (controlled). Pair it with `onValueChange`. Pass `null` (not `undefined`) for a controlled group with nothing selected yet, or to clear the selection; `undefined` makes the group uncontrolled. |
| `defaultValue` | `T` |  | Initially selected value when uncontrolled. Omit both `value` and `defaultValue` to start with nothing selected. |
| `onValueChange` | `((value: T) => void)` |  | Called with the newly selected value (click, Space, or arrow keys). |
| `options` | `readonly RadioCardOption<T>[]` |  | Shortcut: the cards as data. Rendered before any `children`. Use `<RadioCard>` children instead when a card needs extra content (a price, a badge) or a `media` preview. |
| `size` | `"sm" \| "lg"` | `lg` | Card size, see `RadioCardSize`. Defaults to `lg`. |
| `appearance` | `"outline" \| "soft"` | `outline` | Selected look, see `RadioCardAppearance`. Defaults to `outline`. |
| `indicator` | `"none" \| "radio" \| "check"` |  | Selection mark, see `RadioCardIndicator`. Defaults to `check` for `outline` and `radio` for `soft`. |
| `columns` | `1 \| 3 \| 2` |  | Number of columns from the `sm` breakpoint up (always one column on phones). Defaults to 1. For other layouts (e.g. `sm:grid-cols-2 lg:grid-cols-3`), leave it unset and pass grid classes in `className`. |
| `disabled` | `boolean` |  | Disables every card. |
| `aria-label` | `string` |  | Accessible name of the group when no visible label exists. Prefer `aria-labelledby` pointing at a visible label. |
| `aria-labelledby` | `string` |  | Id of the visible element naming the group (a field label rendered as a `<span>`, a section heading). |
| `aria-invalid` | `"true" \| "false" \| "grammar" \| "spelling" \| boolean` |  | Marks the choice as invalid (e.g. `required` and nothing selected after submit): the unselected cards get the destructive border. `Field` sets it for you when it shows an `error`; pair it with a visible error message linked through `aria-describedby`. |
| `form` | `string` |  |  |
| `name` | `string` |  |  |
| `dir` | `"ltr" \| "rtl"` |  |  |
| `asChild` | `boolean` |  |  |
| `loop` | `boolean` |  |  |
| `required` | `boolean` |  |  |
| `orientation` | `"horizontal" \| "vertical"` |  |  |

### RadioCard

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `value` (required) | `string` |  | Value reported to the group's `onValueChange` when this card is selected. |
| `label` (required) | `ReactNode` |  | Card title. Also the accessible name of the radio (via `aria-labelledby`). |
| `description` | `ReactNode` |  | Text under the title. Also the radio's accessible description (via `aria-describedby`). |
| `icon` | `ReactNode` |  | Line icon shown in a bordered box at the start of the card (sized automatically). Ignored with `media`. Decorative. |
| `media` | `ReactNode` |  | Visual preview (a mini screenshot, an illustration) shown full-width above the label instead of the card layout: the preview frame gets the selected ring and the label sits beneath with the group's indicator. Use it for appearance / layout pickers. Decorative: the label names the option. |
| `children` | `ReactNode` |  | Extra non-interactive content under the description (a price, a `Badge`). Never put buttons or links here: the whole card is already a button. |
| `disabled` | `boolean` |  | Makes this card unselectable (dimmed, `not-allowed` cursor). The group's `disabled` disables every card. |
| `aria-describedby` | `string` |  | Ids of extra describing elements (e.g. a note outside the card). Merged after the card's own `description`. |
| `asChild` | `boolean` |  |  |
| `checked` | `boolean` |  |  |
| `required` | `boolean` |  |  |

### RadioCardOption

The type of one item of `options`.

| Key | Type | Role |
| --- | --- | --- |
| `value` | `string` | The value of the card. Required. It must be unique in the group. |
| `label` | `ReactNode` | The title of the card. Required. |
| `description` | `ReactNode` | The text below the title. |
| `icon` | `ReactNode` | A line icon in a box at the start of the card. |
| `disabled` | `boolean` | The user cannot select the card. |
