Skip to content
Download ZIP

Neutral palette · 8.8 KB ZIP File receipt View as Markdown View code

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.
cmp_choice_cards_01 · version 1.0.0 · Neutral palette
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
ChoiceCards.svelte Svelte · 15.0 KB Raw
<!--
	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)">&nbsp;{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>

Pass 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
Required props
legendnameoptions

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

Svelte
<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.

Svelte
<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:

CSS
.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
NameTypeRequiredDefaultDescription
legendstringYesNoneThe question, rendered as the fieldset's legend.
namestringYesNoneThe inputs' name, so the choice submits with the form.
optionsChoiceCardsOption[]YesNoneThe 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.
valuestring | string[]NoNoneThe 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[]) => voidNoNoneCalled with the new value after every change.
hintstringNoNoneGuidance under the legend, read when focus enters the group.
columns1 | 2 | 3 | 4No3Most 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.
mediaSnippet<[ChoiceCardsOption]>NoNoneRenders each card's media in place of option.image, for icons or picture elements.
requiredbooleanNofalseRadios: 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.
requiredLabelstringNo'(required)'Marker after a required legend. Set '' when your form marks optional questions instead.
errorstringNoNoneThe current validation message. Set, it shows under the legend, every card edge takes the error colour and each input gets aria-invalid.
errorPrefixstringNo'Error:'Visually hidden text read before the error. Translate it with the page.
classClassValueNoNoneExtra classes on the fieldset, for width or placement.

Customization#

On this page

Change 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-accent colours the chosen edge, the filled mark and the focus ring; --choice-cards-on-accent is the tick. Keep the accent 3:1 against the surface.
  • Neutrals: --choice-cards-ink is titles and prices, --choice-cards-muted descriptions and the hint, --choice-cards-hairline the resting card edge, --choice-cards-border the mark's ring (keep it 3:1), --choice-cards-surface the card fill.
  • Error: --choice-cards-error colours the message and the card edges. The default is red-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

VariableToken
--choice-cards-accentaccent
--choice-cards-on-accentonAccent
--choice-cards-inkink
--choice-cards-mutedmuted
--choice-cards-hairlinehairline
--choice-cards-borderborder
--choice-cards-surfacesurface
--choice-cards-errorerror

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 through aria-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-invalid and the message joins each input's aria-describedby, so it is read with whichever card has focus. It carries a hidden prefix ('Error:', translate with errorPrefix) 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.