Choice cards
Radio or checkbox choices set as cards in a fieldset: title, description, a price at the foot, optional media and badge. Native inputs, so it submits without JavaScript; rows share their lines through subgrid.
cmp_choice_cards_01 Preview
Fit to the available width. The frame follows the height of its content; previews taller than the maximum auto-height scroll inside it.
Give this component to your coding agent Copy a prompt that fetches this exact version and palette through the PageSugar MCP server.
Using the PageSugar MCP server, fetch component cmp_choice_cards_01 version 1.0.0 with variant "neutral", first inspect its requirements and license status and confirm this project uses Svelte 5 and Tailwind CSS 4. Retrieve every manifest file, including binary assets and any manifest-only response files, preserving relative paths. Then integrate the source and follow its usage notes. Run project checks, review the browser result and report anything unverified. Do not substitute another version or invent missing files.Not connected yet? Set up the MCP server
Code
- Palette
- Neutral
- Version
- 1.0.0
- Digest
Full digest
sha256-4562191bcad42285951550329eb8bfa00d17a3650cd9a446c8fd8439d9c87453
<!--
Radio or checkbox choices set as cards in a fieldset. Each card is a <label> around a visually
hidden native input, so a click anywhere on it chooses, the arrow keys move between radios and
the form submits the values without JavaScript. Cards in a row share their internal lines
through subgrid: titles, descriptions and the price at the foot sit level whatever the copy
length. The chosen card takes a two-pixel accent edge, a filled mark in its corner and the
component's one elevation; the edge is drawn above the card, so nothing changes size.
-->
<script lang="ts" module>
import type { Snippet } from 'svelte';
export interface ChoiceCardsImage {
/** Image URL. */
src: string;
/** Alternative text. Empty (the default) when the title already says what it shows. */
alt?: string;
}
export interface ChoiceCardsOption {
/** Submitted value. Unique within the group. */
value: string;
/** The input's accessible name and the card's heading. */
title: string;
/** Supporting sentence under the title, read after the name. */
description?: string;
/** Price or short fact at the foot of the card, such as "£7.50" or "30 min". */
meta?: string;
/** Short tag after the title, such as "Recommended". Read after the name. */
badge?: string;
/** Image above the text (stacked) or beside it (row). */
image?: ChoiceCardsImage;
/** Shown but not selectable; say why in the description. */
disabled?: boolean;
}
export type ChoiceCardsType = 'radio' | 'checkbox';
export type ChoiceCardsLayout = 'stacked' | 'row';
export type ChoiceCardsColumns = 1 | 2 | 3 | 4;
</script>
<script lang="ts">
import { tick, untrack } from 'svelte';
import type { ClassValue } from 'svelte/elements';
interface Props {
/** The question, rendered as the fieldset's legend. */
legend: string;
/** Guidance under the legend. */
hint?: string;
/** 'radio' for one choice, 'checkbox' for any number. */
type?: ChoiceCardsType;
/** The cards, in reading order. */
options: ChoiceCardsOption[];
/** The chosen value (radio) or values (checkbox). Bindable. */
value?: string | string[];
/** Called with the new value after every change. */
onValueChange?: (value: string | string[]) => void;
/** The inputs' name, so the choice submits with the form. */
name: string;
/** Most columns the cards reflow up to as the container widens. */
columns?: ChoiceCardsColumns;
/** Radio: native required. Checkbox: shown in the legend only. */
required?: boolean;
/** Marker after the legend when required. Empty for none. */
requiredLabel?: string;
/** The current validation message. Set, the group is invalid. */
error?: string;
/** Read before the error by assistive technology. */
errorPrefix?: string;
/** 'stacked' puts media above the text; 'row' puts it beside. */
layout?: ChoiceCardsLayout;
/** Renders each card's media instead of option.image. */
media?: Snippet<[ChoiceCardsOption]>;
/** Extra classes on the fieldset, for width or placement. */
class?: ClassValue;
}
let {
legend,
hint,
type = 'radio',
options,
value = $bindable(),
onValueChange,
name,
columns = 3,
required = false,
requiredLabel = '(required)',
error,
errorPrefix = 'Error:',
layout = 'stacked',
media,
class: className
}: Props = $props();
const uid = $props.id();
const hintId = `${uid}-hint`;
const errorId = `${uid}-error`;
const checkbox = $derived(type === 'checkbox');
/* What is chosen, as a list for both types. A value of the wrong shape counts as none;
any string, '' included, is a radio value, so undefined is the only "nothing chosen". */
const chosen = $derived<string[]>(
checkbox ? (Array.isArray(value) ? value : []) : typeof value === 'string' ? [value] : []
);
let fieldset = $state<HTMLFieldSetElement>();
const inputs = () => [...(fieldset?.querySelectorAll('input') ?? [])];
/* The browser checks an input before onchange runs, so the inputs are set back from the
value afterwards: a parent that rejects or rewrites a change wins over the click. */
async function reconcile() {
await tick();
for (const input of inputs()) input.checked = chosen.includes(input.value);
}
/* A form reset restores the value the component started with, inputs and state together. */
$effect(() => {
const form = fieldset?.form;
if (!form) return;
const initial = untrack(() => $state.snapshot(value));
const onReset = () => {
setTimeout(() => {
value = initial;
if (initial !== undefined) onValueChange?.(initial);
reconcile();
});
};
form.addEventListener('reset', onReset);
return () => form.removeEventListener('reset', onReset);
});
const hasMedia = $derived(Boolean(media) || options.some((option) => option.image));
const stackedMedia = $derived(hasMedia && layout === 'stacked');
const rowMedia = $derived(hasMedia && layout === 'row');
/* Complete class names per setting, so Tailwind finds every one of them. */
const gridColumns: Record<ChoiceCardsColumns, string> = {
1: '',
2: '@md:grid-cols-2',
3: '@md:grid-cols-2 @4xl:grid-cols-3',
4: '@md:grid-cols-2 @4xl:grid-cols-4'
};
/* Never more columns than cards, so one or two options fill the width instead of
leaving empty tracks beside them. */
const effectiveColumns = $derived(
Math.max(1, Math.min(columns, options.length)) as ChoiceCardsColumns
);
function change(option: ChoiceCardsOption, on: boolean) {
const next = checkbox
? options
.filter((o) => (o.value === option.value ? on : chosen.includes(o.value)))
.map((o) => o.value)
: option.value;
value = next;
onValueChange?.(next);
reconcile();
}
</script>
<fieldset
bind:this={fieldset}
class={['choice-cards @container m-0 min-w-0 border-0 p-0', className]}
aria-describedby={hint ? hintId : undefined}
data-invalid={error ? '' : undefined}
>
<legend
class="p-0 text-base leading-6 font-semibold tracking-[-0.011em] text-balance text-(--_ink) rtl:tracking-normal"
>
{legend}{#if required && requiredLabel}
<span class="font-normal text-(--_muted)"> {requiredLabel}</span>
{/if}
</legend>
{#if hint}
<p id={hintId} class="mt-1 max-w-[32em] text-sm leading-5 text-pretty text-(--_muted)">
{hint}
</p>
{/if}
<!-- A required group can fail on submit, so its error line is held open from the first
render and the cards never move when a message arrives or clears. -->
{#if error || required}
<p
id={errorId}
class={[
'mt-2 flex gap-2 text-sm leading-5 font-medium text-(--_error)',
required && 'min-h-5'
]}
>
{#if error}
<span class="flex h-lh shrink-0 items-center" aria-hidden="true">
<svg viewBox="0 0 16 16" class="size-4" fill="none" stroke="currentColor">
<circle cx="8" cy="8" r="6.25" stroke-width="1.5" />
<path d="M8 4.75v3.75" stroke-width="1.75" stroke-linecap="round" />
<circle cx="8" cy="11.25" r="0.9" fill="currentColor" stroke="none" />
</svg>
</span>
<span><span class="sr-only">{errorPrefix} </span>{error}</span>
{/if}
</p>
{/if}
{#if options.length}
<div class={['mt-4 grid grid-cols-1 gap-3', gridColumns[effectiveColumns]]}>
{#each options as option, i (option.value)}
{@const id = `${uid}-${i}`}
{@const on = chosen.includes(option.value)}
<label
data-type={type}
class={[
'choice-cards__card group relative grid grid-rows-subgrid gap-y-0 rounded-xl bg-(--_surface) p-4 select-none',
stackedMedia ? 'row-span-4' : 'row-span-3',
rowMedia && 'grid-cols-[6rem_minmax(0,1fr)] gap-x-4',
'cursor-pointer has-disabled:cursor-not-allowed has-disabled:bg-(--_raised)',
'outline-offset-2 outline-(--_accent) has-focus-visible:outline-2'
]}
>
<input
class="sr-only"
{type}
{name}
value={option.value}
checked={on}
disabled={option.disabled}
required={required && !checkbox ? true : undefined}
aria-invalid={error ? 'true' : undefined}
aria-labelledby="{id}-title"
aria-describedby={[
option.badge && `${id}-badge`,
option.description && `${id}-description`,
option.meta && `${id}-meta`,
error && errorId
]
.filter(Boolean)
.join(' ') || undefined}
onchange={(event) => change(option, event.currentTarget.checked)}
/>
{#if hasMedia}
<div
class={[
'overflow-hidden bg-(--_raised) group-has-disabled:opacity-60',
stackedMedia
? '-mx-4 -mt-4 mb-4 aspect-[16/9] rounded-t-xl'
: 'row-span-3 aspect-[16/9] self-start rounded-sm'
]}
>
{#if media}
{@render media(option)}
{:else if option.image}
<img
src={option.image.src}
alt={option.image.alt ?? ''}
loading="lazy"
class="size-full object-cover"
/>
{/if}
</div>
{/if}
<div class={['flex min-w-0 items-start gap-3', rowMedia && 'col-start-2']}>
<span class="flex min-w-0 flex-1 flex-wrap items-center gap-x-2 gap-y-1">
<span
id="{id}-title"
class="min-w-0 text-base leading-6 font-semibold tracking-[-0.011em] text-pretty break-words text-(--_ink) group-has-disabled:text-(--_muted) rtl:tracking-normal"
>
{option.title}
</span>
{#if option.badge}
<span
id="{id}-badge"
class="rounded-full px-2 text-xs leading-5 font-medium whitespace-nowrap text-(--_ink) ring-1 ring-(--_hairline) ring-inset"
>
{option.badge}
</span>
{/if}
</span>
<!-- The mark: a ring at rest, filled with a tick when chosen. Square for checkboxes. -->
<span class="flex h-6 shrink-0 items-center" aria-hidden="true">
<span
class={[
'choice-cards__mark grid size-5 place-items-center text-(--_on-accent)',
checkbox ? 'rounded-sm' : 'rounded-full'
]}
>
<svg viewBox="0 0 16 16" class="choice-cards__tick size-3.5" fill="none">
<path
d="M3.75 8.25l2.75 2.75 5.75-6"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</span>
</span>
</div>
{#if option.description}
<span
id="{id}-description"
class={[
'pt-1 text-sm leading-5 text-pretty break-words text-(--_muted)',
rowMedia && 'col-start-2'
]}
>
{option.description}
</span>
{:else}
<span class={[rowMedia && 'col-start-2']}></span>
{/if}
{#if option.meta}
<span
id="{id}-meta"
class={[
'pt-3 text-base leading-6 font-semibold text-(--_ink) tabular-nums group-has-disabled:text-(--_muted)',
rowMedia && 'col-start-2'
]}
>
{option.meta}
</span>
{:else}
<span class={[rowMedia && 'col-start-2']}></span>
{/if}
</label>
{/each}
</div>
{/if}
</fieldset>
<style>
/* Public tokens: set --choice-cards-* on the fieldset or any ancestor to retone it. */
.choice-cards {
--_accent: var(--choice-cards-accent, #18181b);
--_on-accent: var(--choice-cards-on-accent, #ffffff);
--_ink: var(--choice-cards-ink, #18181b);
--_muted: var(--choice-cards-muted, #52525b);
--_hairline: var(--choice-cards-hairline, rgb(0 0 0 / 0.12));
/* The mark's ring at rest: a control boundary, so 3:1 against the card. */
--_border: var(--choice-cards-border, rgb(0 0 0 / 0.44));
--_surface: var(--choice-cards-surface, #ffffff);
--_error: var(--choice-cards-error, #b91c1c);
--_raised: color-mix(in oklab, var(--_ink) 4%, var(--_surface));
--_hairline-hover: color-mix(in oklab, var(--_hairline), var(--_ink) 20%);
--_ease: cubic-bezier(0.2, 0, 0, 1);
/* The chosen edge sits a step softer than the mark, which keeps the full accent. */
--_accent-edge: color-mix(in oklab, var(--_accent) 84%, var(--_surface));
--_shadow-chosen: 0 1px 2px rgb(0 0 0 / 0.05), 0 12px 40px rgb(0 0 0 / 0.08);
}
/*
* The card's edge is an inset ring on a box laid over the card, so it draws above full-bleed
* media and thickens to two pixels without moving anything. It reads the card's state through
* :has(), so the edge, the elevation and their transition live together here.
*/
.choice-cards__card {
--_edge: var(--_hairline);
--_edge-width: 1px;
transition-property: box-shadow, scale;
transition-duration: 150ms, 80ms;
transition-timing-function: var(--_ease);
}
/* Press: a slight give on the whole card, never on a disabled one. */
.choice-cards__card:active:not(:has(:disabled)) {
scale: 0.99;
}
.choice-cards__card::after {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
pointer-events: none;
box-shadow: inset 0 0 0 var(--_edge-width) var(--_edge);
transition: box-shadow 150ms var(--_ease);
}
@media (hover: hover) {
.choice-cards__card:hover:not(:has(:disabled, :checked)) {
--_edge: var(--_hairline-hover);
}
}
.choice-cards[data-invalid] .choice-cards__card {
--_edge: var(--_error);
}
.choice-cards .choice-cards__card:has(:checked) {
--_edge: var(--_accent-edge);
--_edge-width: 2px;
}
/* The one elevation belongs to the one chosen radio. Several chosen checkboxes stay flat
on their weighted edges and filled marks, so the group never holds more than one lift. */
.choice-cards .choice-cards__card[data-type='radio']:has(:checked) {
box-shadow: var(--_shadow-chosen);
}
/* An error outranks the accent: a chosen card that fails validation keeps its weight and
mark but takes the error colour. */
.choice-cards[data-invalid] .choice-cards__card:has(:checked) {
--_edge: var(--_error);
}
/* The mark: a 3:1 ring at rest, the accent fill and tick when chosen. */
.choice-cards__mark {
box-shadow: inset 0 0 0 1px var(--_border);
transition:
background-color 150ms var(--_ease),
box-shadow 150ms var(--_ease);
}
.choice-cards__tick {
opacity: 0;
transition: opacity 150ms var(--_ease);
}
.choice-cards__card:has(:checked) .choice-cards__mark {
background-color: var(--_accent);
box-shadow: inset 0 0 0 1px var(--_accent);
}
.choice-cards__card:has(:checked) .choice-cards__tick {
opacity: 1;
}
.choice-cards__card:has(:disabled) .choice-cards__mark {
box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--_border) 55%, transparent);
}
@media (prefers-reduced-motion: reduce) {
.choice-cards__card,
.choice-cards__card::after,
.choice-cards__mark,
.choice-cards__tick {
transition: none;
}
.choice-cards__card:active:not(:has(:disabled)) {
scale: none;
}
}
/*
* Forced colours drop box-shadows, so the edges and the mark become real outlines: a thin
* system-colour edge at rest, a thick Highlight edge and a filled mark when chosen.
*/
@media (forced-colors: active) {
.choice-cards__card::after {
border: 1px solid CanvasText;
}
.choice-cards__card:has(:checked)::after {
border: 3px solid Highlight;
}
.choice-cards__mark {
border: 1px solid CanvasText;
}
.choice-cards__card:has(:checked) .choice-cards__mark {
forced-color-adjust: none;
background-color: Highlight;
color: HighlightText;
}
}
</style>
Usage#
On this pagePass a legend, a name and the options; bind value to read the choice. The cards render native inputs inside a fieldset, so a plain form submits them. It does not calculate prices or totals, check stock or availability, validate the choice, or decide when to show an error: prices are display strings you supply and the error is the message you pass.
- Suggested location
src/lib/components/choice-cards-01
Limitations
- Subgrid row alignment needs a browser with CSS subgrid (all current engines); older ones show equal-height cards whose inner lines do not align.
- required is native only for radios. HTML has no group-level required for checkboxes, so with type='checkbox' it adds the legend marker and you check the count yourself.
- Do not put links or buttons inside option text: the whole card is a label, so a click on them would also toggle the input.
- Give every option media or none; a card without media in a media set keeps its media area empty so the row's lines still line up.
- Option values key the list and must be unique.
- A form reset restores the value the component was first rendered with and reports it through
onValueChange.
Example
<script lang="ts">
import ChoiceCards from '$lib/components/choice-cards-01/ChoiceCards.svelte';
let delivery = $state('standard');
</script>
<form method="post">
<ChoiceCards
legend="How should we get your order to you?"
name="delivery"
bind:value={delivery}
required
options={[
{ value: 'standard', title: 'Standard delivery', description: 'Two to three working days.', meta: '£3.95' },
{ value: 'express', title: 'Next-day delivery', description: 'Order by 2 pm for delivery tomorrow.', meta: '£7.50' },
{ value: 'collect', title: 'Collect from the shop', description: 'Ready from 10 am.', meta: 'Free' }
]}
/>
<button type="submit">Continue to payment</button>
</form>The source imports its own files by relative path only. Examples import from $lib/...; SvelteKit 3 projects have no $lib alias by default, so import from your #lib subpath (with the .svelte extension) or a relative path instead.
Choice cards#
Radio or checkbox choices set as cards, for delivery methods, plan choice, add-ons and
onboarding questions. Each card is a <label> around a native input, so the group works in a
plain HTML form: clicking anywhere on a card chooses it, the arrow keys move between radios, and
the checked values submit with the form without JavaScript.
<script lang="ts">
import ChoiceCards from '$lib/components/choice-cards-01/ChoiceCards.svelte';
let delivery = $state('standard');
</script>
<form method="post">
<ChoiceCards
legend="How should we get your order to you?"
name="delivery"
bind:value={delivery}
required
options={[
{
value: 'standard',
title: 'Standard delivery',
description: 'Two to three working days.',
meta: '£3.95'
},
{
value: 'express',
title: 'Next-day delivery',
description: 'Order by 2 pm.',
meta: '£7.50'
},
{
value: 'collect',
title: 'Collect from the shop',
description: 'Ready from 10 am.',
meta: 'Free'
}
]}
/>
<button type="submit">Continue to payment</button>
</form>Radio or checkbox#
type="radio" (the default) takes a string value; type="checkbox" takes a string[] and
reports the chosen values in option order. A value of the wrong shape is treated as nothing
chosen. Radios carry a round mark and checkboxes a square one, so one-of and many-of read
differently at a glance.
Columns and rows#
columns is the most the grid reflows up to, measured against the component's own width (a
container query), not the viewport, and never more than the number of options, so one or two
cards fill the width instead of leaving empty tracks:
columns |
Under 448 px | 448 px and up | 672 px and up | 896 px and up |
|---|---|---|---|---|
| 1 | 1 | 1 | 1 | 1 |
| 2 | 1 | 2 | 2 | 2 |
| 3 | 1 | 2 | 2 | 3 |
| 4 | 1 | 2 | 2 | 4 |
Cards in a row share their internal lines through CSS subgrid: the media, the title line, the
description and the meta line each sit level across the row, and every card in a row is the
same height. Put the price or the one fact people compare in meta, which sits at the foot.
Media#
Give an option image: { src, alt }, or pass a media snippet that receives each option and
renders its own media (an icon, an inline SVG, a picture element). With layout="stacked" the
media fills the top of the card at 16:9; with layout="row" it sits beside the text at 96 px
wide, at the same 16:9, so one set of images serves both layouts. Give every option media, or none: a card without it leaves its media area empty
so the row's lines still line up.
alt defaults to empty, because the title usually says what the picture shows. Set it when the
image carries information the text does not.
Validation#
error shows a message under the legend with a mark and a hidden "Error:" prefix, turns every
card edge to the error colour and marks each input aria-invalid. The component never decides
when to show it; set it after your own validation, usually on submit.
required puts native required on every radio, so the browser blocks a submit with nothing
chosen. It also holds the error line open under the legend from the first render, so the cards
stay put when a message arrives or clears. An optional group adds the line only when error is
set. The held line is one line tall: keep required errors short ("Choose how you want
to receive your order"), as a second line would still push the cards down. HTML has no group-level required for checkboxes, so with type="checkbox" it only adds
the legend's marker; check the count yourself.
Retone#
Every colour goes through a --choice-cards-* variable. For a cream bakery page:
.checkout {
--choice-cards-accent: #7c2d12;
--choice-cards-surface: #fffdf8;
--choice-cards-hairline: rgb(68 32 8 / 0.14);
}Props and content inputs#
On this page| Name | Type | Required | Default | Description |
|---|---|---|---|---|
legend | string | Yes | None | The question, rendered as the fieldset's legend. |
name | string | Yes | None | The inputs' name, so the choice submits with the form. |
options | ChoiceCardsOption[] | Yes | None | The cards, in reading order: { value, title, description?, meta?, badge?, image?: { src, alt? }, disabled? }. meta is the price or fact at the foot. |
type | 'radio' | 'checkbox' | No | 'radio' | radio for one choice, checkbox for any number. |
value | string | string[] | No | None | The chosen value (radio, any string including '') or values (checkbox, an array in option order). undefined is nothing chosen. Bindable; a parent that rejects or rewrites a change wins over the click. |
onValueChange | (value: string | string[]) => void | No | None | Called with the new value after every change. |
hint | string | No | None | Guidance under the legend, read when focus enters the group. |
columns | 1 | 2 | 3 | 4 | No | 3 | Most columns the cards reflow up to as the component's own width grows, and never more than the number of options; always one column under 448 px. |
layout | 'stacked' | 'row' | No | 'stacked' | stacked: media fills the top of each card at 16:9. row: media sits beside the text, 96 px wide at the same 16:9. |
media | Snippet<[ChoiceCardsOption]> | No | None | Renders each card's media in place of option.image, for icons or picture elements. |
required | boolean | No | false | Radios: native required on every input. Both types: shows requiredLabel after the legend and holds the error line open from the first render, so the cards do not move when an error arrives. |
requiredLabel | string | No | '(required)' | Marker after a required legend. Set '' when your form marks optional questions instead. |
error | string | No | None | The current validation message. Set, it shows under the legend, every card edge takes the error colour and each input gets aria-invalid. |
errorPrefix | string | No | 'Error:' | Visually hidden text read before the error. Translate it with the page. |
class | ClassValue | No | None | Extra classes on the fieldset, for width or placement. |
Customization#
On this pageChange the words and media through props and retone the cards through eight --choice-cards-* CSS variables. Hover edges and the raised disabled fill are mixed from those, so they follow any retone.
- Content: put the price or the one fact people compare in meta; it sits at the foot of every card on a shared line. Keep descriptions to one sentence and say why a disabled option is unavailable.
- Accent:
--choice-cards-accentcolours the chosen edge, the filled mark and the focus ring;--choice-cards-on-accentis the tick. Keep the accent 3:1 against the surface. - Neutrals:
--choice-cards-inkis titles and prices,--choice-cards-muteddescriptions and the hint,--choice-cards-hairlinethe resting card edge,--choice-cards-borderthe mark's ring (keep it 3:1),--choice-cards-surfacethe card fill. - Error:
--choice-cards-errorcolours the message and the card edges. The default isred-700(6.5:1 on white). - Worked retone for a cream bakery page:
--choice-cards-accent:#7c2d12;--choice-cards-surface:#fffdf8;--choice-cards-hairline: rgb(68 32 8 / 0.14);--choice-cards-border: rgb(68 32 8 / 0.5). - Columns: set columns to the most a row should hold; the grid steps down by the component's own width, so the same group works in a sidebar and a full checkout column.
- Media: image fills the top at 16:9 (stacked) or sits beside the text (row). Use a media snippet for icons; give every option media or none.
Public CSS variables
| Variable | Token |
|---|---|
--choice-cards-accent | accent |
--choice-cards-on-accent | onAccent |
--choice-cards-ink | ink |
--choice-cards-muted | muted |
--choice-cards-hairline | hairline |
--choice-cards-border | border |
--choice-cards-surface | surface |
--choice-cards-error | error |
Accessibility#
On this page- The group is a fieldset with the question as its legend; the hint is the fieldset's
aria-describedby, so it is read when focus enters the group. - Each card is a label around a native radio or checkbox, visually hidden but focusable. Radios sharing a name give the platform's arrow-key movement and one tab stop; checkboxes are one tab stop each.
- The input is named by the title alone (
aria-labelledby); the badge, description and meta follow througharia-describedby, so the name stays short. - The chosen state is shown by a filled mark with a tick, a two-pixel edge and an elevation, not colour alone. Radios have round marks and checkboxes square ones.
- Focus draws a two-pixel accent ring two pixels outside the whole card, via :has(:focus-visible).
- With an error every input gets
aria-invalidand the message joins each input'saria-describedby, so it is read with whichever card has focus. It carries a hidden prefix ('Error:', translate witherrorPrefix) and a mark. It is not a live region: announce errors on submit with an error summary and move focus yourself. - Disabled options keep their place and text so people can read why; they are skipped by the arrow keys.
- In forced-colours mode the edges and marks become real system-colour borders, with Highlight for the chosen card.
- Media is decorative by default (empty alt). Give it alt text when it carries information the title does not.
Release details#
On this page- Integration
- Local interaction
- Works without client-side JavaScript
- Server-side rendering supported
- Dependencies
- No additional runtime packages beyond Svelte and Tailwind CSS
- License
MIT. Default license approval is pending; see the license status before adopting the source.
- Version history
- 1.0.0 (Published) Current release · 22 October 2026
Only the current release is available. Keep downloaded source and its receipt if you need to use it again later.