Canvas Design System
Main Site Tokens

Radio Card Group

A one-from-many picker where each option is a card, so the choices are visible without opening anything.

When to Use

Use when: The user picks exactly one of two to eight options and seeing them all at once helps — export formats, workshop templates, session types. The icons and descriptions do work a dropdown can't.
Don't use when: There are many options or they're purely textual — use Select. For multiple selection use Checkbox Card Group; for switching views rather than choosing a value, Tabs.

Variants

Four columns (default)

Best for short labels with icons. Exactly one item should carry checked.

Select format

<?= ws_radio_card_group('exportFormat', [
    ['value' => 'pdf',      'label' => 'PDF Document',  'icon' => 'description', 'checked' => true],
    ['value' => 'docx',     'label' => 'Word Document', 'icon' => 'article'],
    ['value' => 'markdown', 'label' => 'Markdown',      'icon' => 'code'],
], ['label' => 'Select format']) ?>

Three columns with descriptions

Fewer columns give each card room for a description. Use it when the label alone doesn't distinguish the options.

Template type

<?= ws_radio_card_group('templateType', $items, [
    'columns' => 3,
    'label'   => 'Template type',
]) ?>

Two columns

For a binary choice that still deserves explanation. If neither option needs a description, a Toggle is lighter.

How will you run it?

<?= ws_radio_card_group('sessionMode', $items, ['columns' => 2]) ?>

States

StateBehavior
UnselectedDefault card treatment, driven by the input's unchecked state rather than a class.
SelectedSet by checked on the item. Styling follows the native :checked pseudo-class, so it stays correct after the user interacts — no JS keeps it in sync.
HoverBorder and background shift on the card label.
FocusThe real radio input receives focus and its ring shows on the card. Don't hide the input with display: none — that removes it from the tab order.
Disableddisabled on an item sets the native attribute, so the option is skipped by keyboard and can't be chosen.
Nothing checkedValid but rarely wanted — the group renders with no selection. Mark a sensible default unless "no choice yet" is meaningful.

Real-World Usage

The export dialog: format options rendered from config, the user's last choice pre-selected, and the group inside a form so the value posts under the group name.

Export as

<?= ws_radio_card_group('exportTarget', array_map(fn($f) => [
    'value'       => $f['key'],
    'label'       => $f['name'],
    'icon'        => $f['icon'],
    'description' => $f['blurb'],
    'checked'     => $f['key'] === ($prefs['last_export'] ?? 'pdf'),
], $formats), ['columns' => 3, 'label' => 'Export as']) ?>

Options

Group options

OptionTypeDefaultPurpose
$namestring''First positional. The shared radio name — what makes the options mutually exclusive, and the key the value posts under.
$itemsarray[]Second positional. The option definitions.
labelstringnullGroup label. Not programmatically associated — see Accessibility.
columnsint42 | 3 | 4
idstring'{name}-group'Container ID
classstring''Additional CSS classes

Item options

OptionTypeDefaultPurpose
valuestring''Submitted value
labelstring''Card title
iconstringnullMaterial icon above the label
descriptionstringnullSupporting line. Best at two or three columns.
checkedboolfalsePre-selected. Mark exactly one.
disabledboolfalseNative disabled state

Accessibility

ConcernBehavior
SemanticsReal <input type="radio"> elements sharing one name. Grouping, arrow-key navigation, and form submission are all native — a genuine strength over a div-based picker.
KeyboardNative radio behaviour: Tab enters the group at the selected option, then /// move between options and select as they go. Nothing custom is needed, and nothing custom is present.
Labelling per optionEach card is a <label> wrapping its input, so the whole card is both the click target and the accessible name. Icon, title, and description are all announced.
Group labelNot associated. The label option renders as a plain element with no <fieldset>/<legend> and no role="radiogroup" with aria-labelledby. Screen-reader users hear each option but not what the group is asking. Wrap the call in your own <fieldset><legend> when the question isn't obvious from surrounding text.
FocusComes from the input. Keep it in the accessibility tree — visually-hidden techniques are fine, display: none is not.
ContrastThe selected state is marked by border and background together, not colour alone.
<fieldset>
    <legend>Select format</legend>
    <?= ws_radio_card_group('exportFormat', $items) ?>
</fieldset>

Tokens

TokenUsed for
--color-ink-secondaryGroup label colour
--text-smallGroup label size
--space-3Grid gap between cards

The cards themselves inherit surface, border, and radius tokens from the shared card rules rather than defining their own.

CSS Classes

ClassPurpose
.ws-radio-card-groupGrid container
.ws-radio-card-group--cols-2 / --cols-3 / --cols-4Column count
.ws-radio-card-group__labelGroup label (unassociated — see Accessibility)

Files

FilePurpose
includes/components/helpers.phpws_radio_card_group() helper function
includes/components/radio-card-group.phpTemplate — grid and per-option label/input markup
includes/components/components.cssStyles (.ws-radio-card-group rules)