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
| State | Behavior |
|---|---|
| Unselected | Default card treatment, driven by the input's unchecked state rather than a class. |
| Selected | Set 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. |
| Hover | Border and background shift on the card label. |
| Focus | The 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. |
| Disabled | disabled on an item sets the native attribute, so the option is skipped by keyboard and can't be chosen. |
| Nothing checked | Valid 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
| Option | Type | Default | Purpose |
|---|---|---|---|
$name | string | '' | First positional. The shared radio name — what makes the options mutually exclusive, and the key the value posts under. |
$items | array | [] | Second positional. The option definitions. |
label | string | null | Group label. Not programmatically associated — see Accessibility. |
columns | int | 4 | 2 | 3 | 4 |
id | string | '{name}-group' | Container ID |
class | string | '' | Additional CSS classes |
Item options
| Option | Type | Default | Purpose |
|---|---|---|---|
value | string | '' | Submitted value |
label | string | '' | Card title |
icon | string | null | Material icon above the label |
description | string | null | Supporting line. Best at two or three columns. |
checked | bool | false | Pre-selected. Mark exactly one. |
disabled | bool | false | Native disabled state |
Accessibility
| Concern | Behavior |
|---|---|
| Semantics | Real <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. |
| Keyboard | Native 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 option | Each 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 label | Not 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. |
| Focus | Comes from the input. Keep it in the accessibility tree — visually-hidden techniques are fine, display: none is not. |
| Contrast | The 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
| Token | Used for |
|---|---|
--color-ink-secondary | Group label colour |
--text-small | Group label size |
--space-3 | Grid gap between cards |
The cards themselves inherit surface, border, and radius tokens from the shared card rules rather than defining their own.
CSS Classes
| Class | Purpose |
|---|---|
.ws-radio-card-group | Grid container |
.ws-radio-card-group--cols-2 / --cols-3 / --cols-4 | Column count |
.ws-radio-card-group__label | Group label (unassociated — see Accessibility) |
Files
| File | Purpose |
|---|---|
includes/components/helpers.php | ws_radio_card_group() helper function |
includes/components/radio-card-group.php | Template — grid and per-option label/input markup |
includes/components/components.css | Styles (.ws-radio-card-group rules) |