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

- ID: `cmp_choice_cards_01`
- Slug: `choice-cards-01`
- Version: `1.0.0` (current)
- Status: published
- Published: 2026-10-22T05:21:38Z
- Updated: 2026-10-22
- Available versions: `1.0.0`
- Kind: control
- Primary category: `selection`
- Detail page: https://pagesugar.com/components/choice-cards-01?variant=neutral
- Preview: https://pagesugar.com/preview/choice-cards-01

## Variants

| Variant | Label | Default | Artifact digest |
| --- | --- | --- | --- |
| `neutral` | Neutral | yes | `sha256-4562191bcad42285951550329eb8bfa00d17a3650cd9a446c8fd8439d9c87453` |
| `blue` | Blue accent | no | `sha256-62fd44ec5ea8990c99b4c7a6c726e1653efebbc0ada313e23f4c73a4c23b5c15` |

## Runtime and compatibility

- Runtime: svelte
- Svelte: 5
- SvelteKit required: no (portable Svelte component)
- Imports: 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.
- Tailwind CSS: 4
- SSR: supported
- Requires client-side JavaScript: no
- Integration level: local-interaction
- Appearance modes: light
- Suggested directory: `src/lib/components/choice-cards-01`

## Dependencies

No third-party runtime packages.

## Services

No external services required.

## Usage

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.

Required props: `legend`, `name`, `options`

```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>
```

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.

## Usage guide

### 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

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `legend` | `string` | yes |  | The question, rendered as the fieldset's legend. |
| `name` | `string` | yes |  | The inputs' name, so the choice submits with the form. |
| `options` | `ChoiceCardsOption[]` | yes |  | 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 |  | 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 |  | Called with the new value after every change. |
| `hint` | `string` | no |  | 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 |  | 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 |  | 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 |  | Extra classes on the fieldset, for width or placement. |

## Customization

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.

| Token | Public CSS variable |
| --- | --- |
| `accent` | `--choice-cards-accent` |
| `onAccent` | `--choice-cards-on-accent` |
| `ink` | `--choice-cards-ink` |
| `muted` | `--choice-cards-muted` |
| `hairline` | `--choice-cards-hairline` |
| `border` | `--choice-cards-border` |
| `surface` | `--choice-cards-surface` |
| `error` | `--choice-cards-error` |

## Accessibility

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

## License

- Declared source: MIT
- Default license approval is pending. See https://pagesugar.com/docs/license.

## Source

- Palette: Neutral (`neutral`)
- Entry: `ChoiceCards.svelte`
- Suggested directory: `src/lib/components/choice-cards-01`
- Files: 1
- Artifact digest: `sha256-4562191bcad42285951550329eb8bfa00d17a3650cd9a446c8fd8439d9c87453`

Paths below are relative to the suggested directory. Copy the files as they are;
they import nothing from this site.

#### `ChoiceCards.svelte`

Role: entry · 15313 bytes · SHA-256 `4624126290286cf2a0bd102b6b0541391ab489ca72467eee0a6a330c0ced1ade`

```svelte
<!--
	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>
```

## Artifacts

### Neutral (`neutral`) (default)

- Artifact digest: `sha256-4562191bcad42285951550329eb8bfa00d17a3650cd9a446c8fd8439d9c87453`
- Entry: `ChoiceCards.svelte`
- Receipt: https://pagesugar.com/artifacts/cmp_choice_cards_01/1.0.0/neutral/sha256-4562191bcad42285951550329eb8bfa00d17a3650cd9a446c8fd8439d9c87453/manifest.json
- Bundle: https://pagesugar.com/artifacts/cmp_choice_cards_01/1.0.0/neutral/sha256-4562191bcad42285951550329eb8bfa00d17a3650cd9a446c8fd8439d9c87453/bundle.zip (9040 bytes, sha256 `1717c2ffcea12ab9e88b846e5972684f2801d52937cd0afab4be3800a62c39fb`)

Files:

- `ChoiceCards.svelte` (entry, 15313 bytes): https://pagesugar.com/artifacts/cmp_choice_cards_01/1.0.0/neutral/sha256-4562191bcad42285951550329eb8bfa00d17a3650cd9a446c8fd8439d9c87453/source/ChoiceCards.svelte
