# Radio group

> A fieldset of native radios for exactly one choice, with a legend, help text, per-option descriptions and price or date notes, vertical or wrapping horizontal layout, and a group error. Works in a form before hydration.

- ID: `cmp_radio_group_01`
- Slug: `radio-group-01`
- Version: `1.0.0` (current)
- Status: published
- Published: 2026-10-22T05:20:56Z
- Updated: 2026-10-22
- Available versions: `1.0.0`
- Kind: control
- Primary category: `selection`
- Detail page: https://pagesugar.com/components/radio-group-01
- Preview: https://pagesugar.com/preview/radio-group-01

## Variants

| Variant | Label | Default | Artifact digest |
| --- | --- | --- | --- |
| `neutral` | Neutral | yes | `sha256-06a477de59130bd7e85add0d14d69c1c14abeb8133b211e475eae9edadb15c0d` |
| `blue` | Blue accent | no | `sha256-f5bf6f09b438c7a2ff6003dc13f9972e29a02b74c58dbabf76f9b9cfa4feaf7f` |

## 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/radio-group-01`

## Dependencies

No third-party runtime packages.

## Services

No external services required.

## Usage

Give it a legend, a name and the options, and it renders a fieldset of native radios that submits the chosen value with its form before any script runs. Bind value or pass onValueChange to follow the choice, and set error when your own validation fails. It does not validate, does not deselect once something is chosen (native behaviour), and does not style options as cards.

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

```svelte
<script lang="ts">
	import RadioGroup from '$lib/components/radio-group-01/RadioGroup.svelte';

	let delivery = $state<string | undefined>('standard');
	let error = $state<string | null>(null);

	const options = [
		{ value: 'standard', label: 'Standard', description: 'Arrives in 3 to 5 working days.', meta: 'Free' },
		{ value: 'express', label: 'Express', description: 'Arrives the next working day.', meta: '$9.00' }
	];

	function check(event: SubmitEvent) {
		error = delivery ? null : 'Choose a delivery speed.';
		if (error) event.preventDefault();
	}
</script>

<form method="POST" class="max-w-md" onsubmit={check}>
	<RadioGroup legend="Delivery speed" name="delivery" required {options} bind:value={delivery} {error} />
</form>
```

Limitations:

- The component never validates. With required set, the browser's own validation still blocks an empty submit unless the form sets novalidate.
- Once an option is chosen it cannot be cleared by the person, as with any native radio group. Add a 'No preference' option when none is a valid answer.
- Required groups hold one line under the options for the error, so a one-line error moves nothing below. Pass reserveError a line count when your message wraps; an error longer than the reserved lines still grows the group.
- Option values must be unique within the group; they key the rows.
- Keep descriptions to one or two short lines and notes to a few characters (a price, a time). Long notes push the label narrower; put longer detail in the description.
- Horizontal layout suits two to four short answers. Options with descriptions read better vertical.

## Usage guide

### Radio group

A fieldset of native radios for one choice from a short, visible set: delivery speed, billing
interval, a survey answer. Because every option is a real `<input type="radio">` sharing one
`name`, the browser supplies the keyboard model (arrow keys move and select, one tab stop) and
the group submits with its form before any JavaScript runs.

#### Options

Each option takes a `value` and a `label`, plus three optional fields:

- `description`: one or two short lines under the label, for what the option means.
- `meta`: a short note at the end of the label's line, for the one thing people compare across
  options (a price, a time, a size). It is set in tabular numerals and never wraps.
- `disabled`: the option stays visible, faded, and arrow keys skip it. Say why in its
  description.

The label alone names the radio; the description, the note and the group error describe it.

#### Validation

The component never validates. Set `required` and the browser blocks an empty submit; set `error`
from your own code to show a message under the options. Clear it once the person chooses:

```svelte
<RadioGroup
	legend="Preferred appointment time"
	name="time-of-day"
	required
	{options}
	bind:value={time}
	error={submitted && !time ? 'Choose a time of day so we can offer you a slot.' : null}
	reserveError
/>
```

`reserveError` holds one line for the message, so content below does not move when it appears.

#### Layout

Vertical is the default. `orientation="horizontal"` sets the options in a wrapping row once the
group is at least 24rem wide (a container query, so it responds to its column, not the
viewport), and stacks them below that. Use it for two to four short answers.

#### What it does not do

- It does not style options as cards; that is the choice cards pattern.
- It does not let the person clear a choice once made. Add a "No preference" option when none
  is a valid answer.

## Props

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `legend` | `string` | yes |  | The question or group label, rendered as the fieldset's legend. |
| `options` | `RadioGroupOption[]` | yes |  | The choices, in order: { value: string; label: string; description?: string; meta?: string; disabled?: boolean }. meta is a short note at the end of the label's line, such as a price or a date. |
| `name` | `string` | yes |  | Shared input name, so native submission includes the chosen value. |
| `value` | string \| undefined | no |  | Bindable selected value. Undefined leaves every option unselected. |
| `required` | `boolean` | no | `false` | Native required on every radio, so an empty group blocks submission. Shows the Required marker unless requiredIndicator says otherwise. |
| `requiredIndicator` | 'required' \| 'optional' \| 'none' | no |  | Which marker follows the legend. Defaults to 'required' for required groups and 'none' otherwise. |
| `requiredLabel` | `string` | no | `'Required'` | Marker text for required groups. |
| `optionalLabel` | `string` | no | `'Optional'` | Marker text for optional groups. |
| `orientation` | 'vertical' \| 'horizontal' | no | `'vertical'` | 'horizontal' sets the options in a wrapping row once the group is 24rem wide; narrower, they stack. |
| `description` | `string` | no |  | Help text under the legend, linked to the fieldset with aria-describedby. |
| `error` | string \| null | no | `null` | Validation message from your code. When set, it shows under the options with an icon, turns the unselected radios and focus ring to the danger colour, and every radio gets aria-invalid and references it. |
| `errorPrefix` | `string` | no | `'Error:'` | Visually hidden text read before the error. |
| `reserveError` | boolean \| number | no |  | Hold space under the options for the error, so setting it moves nothing below: true holds one line, a number holds that many (use 2 when a translated message wraps at your narrowest width). Defaults to the value of required, since required groups are the ones that show a 'choose one' error. |
| `disabled` | `boolean` | no | `false` | Disable the whole group through the fieldset. Disabled radios leave the tab order and do not submit. |
| `onValueChange` | `(value: string) => void` | no |  | Called with the new value when the person picks an option. |
| `class` | `string` | no |  | Extra classes on the outer wrapper, for placement. |

## Customization

Change the copy through props and retone the group through seven --radio-group-\* CSS variables. Hover and the error edge are mixed from those, so they follow any retone.

- Accent: --radio-group-accent fills the chosen radio and draws the focus ring; --radio-group-on-accent is the dot inside it. Keep the accent at 3:1 against the page.
- Danger: --radio-group-danger colours the error message, its icon, the unselected radios' edge and the focus ring while the group is invalid. The default is red-700 (6.5:1 on white).
- Neutrals: --radio-group-ink is the legend, labels and notes, --radio-group-muted the help, descriptions and marker, --radio-group-hairline the unselected radio's edge (keep it at 3:1 against the page, since it is the only thing marking an empty radio) and --radio-group-surface the empty radio's fill.
- Worked retone for a #09090b band: --radio-group-accent: #fafafa; --radio-group-on-accent: #09090b; --radio-group-ink: #fafafa; --radio-group-muted: #a1a1aa; --radio-group-hairline: rgb(255 255 255 / 0.45); --radio-group-surface: #18181b; --radio-group-danger: #f87171.
- Cream page such as a bakery's #fbf7f0: leave the hairline alone (it is alpha black, so it tints with the page) and set --radio-group-surface: #fffdf9.
- Content: keep labels short and put the detail in description; use meta for the one thing people compare across options (price, time, size).
- Layout: the group fills its container. Use orientation 'horizontal' for two to four short answers; stack groups and fields with a 20 to 24 px gap and pass class for placement.
- Markers: when most questions are required, set requiredIndicator 'none' on them and 'optional' on the rest. Translate requiredLabel, optionalLabel and errorPrefix with the page.

| Token | Public CSS variable |
| --- | --- |
| `accent` | `--radio-group-accent` |
| `onAccent` | `--radio-group-on-accent` |
| `danger` | `--radio-group-danger` |
| `ink` | `--radio-group-ink` |
| `muted` | `--radio-group-muted` |
| `hairline` | `--radio-group-hairline` |
| `surface` | `--radio-group-surface` |

## Accessibility

- Native radios sharing one name implement the APG radio group keyboard model: Tab enters and leaves the group at the chosen radio, arrow keys move and select, Space selects. With nothing chosen, the browser decides where Tab enters (usually the first radio, or the last when tabbing backwards). Disabled options are skipped.
- The fieldset's legend names the group; the help text describes the fieldset. Each radio is named by its label only, and described by its description, its note and, while invalid, the group error. Every id comes from $props.id(), so two groups never collide.
- required is set on every radio, so the browser blocks an empty submit and screen readers announce the group as required. The Required marker is visible text hidden from assistive technology; the Optional marker is part of the legend.
- The error is never colour alone: it has an icon, text and a visually hidden errorPrefix, and every radio carries aria-invalid. It is not a live region; move focus to the group or announce a summary when a submit fails, as your form decides.
- The whole row is the click target, 44 px tall on touch screens. Focus draws a two-pixel ring around the radio in the accent (the danger colour while invalid); keep the accent at 3:1 against the page.
- In forced-colours mode the custom radio gives way to the system radio, so the checked state stays visible in every high-contrast theme.

## License

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

## Source

- Palette: Neutral (`neutral`)
- Entry: `RadioGroup.svelte`
- Suggested directory: `src/lib/components/radio-group-01`
- Files: 1
- Artifact digest: `sha256-06a477de59130bd7e85add0d14d69c1c14abeb8133b211e475eae9edadb15c0d`

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

#### `RadioGroup.svelte`

Role: entry · 10550 bytes · SHA-256 `bb54f1c1ec3bc65f9077420933f83c0aebfb2dd157be5a7ac22660feb1856f53`

```svelte
<!--
	A radio group for exactly one choice: a fieldset and legend, an optional help line, one row per
	option and a consumer-supplied group error. Every option is a native <input type="radio"> sharing
	one name, so arrow keys, the single tab stop and required validation come from the browser and the
	group submits with its form before any script runs.

	Each row is typeset on two edges: the radio sits in a mark column level with the label's first
	line, the label and its description hang from one text edge, and an optional note (a price, a
	date) sits at the row's end in tabular numerals, so options compare like lines of a price list.
-->
<script lang="ts" module>
	export interface RadioGroupOption {
		/** Submitted value; unique within the group. */
		value: string;
		/** Visible label and the radio's accessible name. */
		label: string;
		/** One short line under the label, linked to the radio with aria-describedby. */
		description?: string;
		/** A short note at the end of the label's line, such as a price or a date. */
		meta?: string;
		/** Unavailable: shown faded, skipped by arrow keys, never submitted. */
		disabled?: boolean;
	}
	export type RadioGroupOrientation = 'vertical' | 'horizontal';
	export type RadioGroupRequiredIndicator = 'required' | 'optional' | 'none';
</script>

<script lang="ts">
	interface Props {
		/** The question or group label. Always visible. */
		legend: string;
		/** The choices, in order. */
		options: RadioGroupOption[];
		/** Shared input name, so native submission includes the choice. */
		name: string;
		/** Bindable selected value. Undefined leaves every option unselected. */
		value?: string;
		/** Native required on every radio: an empty group blocks submission. */
		required?: boolean;
		/** Which marker follows the legend. Defaults to 'required' for required groups, otherwise 'none'. */
		requiredIndicator?: RadioGroupRequiredIndicator;
		/** Marker text for required groups, for other languages. */
		requiredLabel?: string;
		/** Marker text for optional groups, for other languages. */
		optionalLabel?: string;
		/** 'horizontal' sets the options in a wrapping row once the group is 24rem wide. */
		orientation?: RadioGroupOrientation;
		/** Help text under the legend, linked to the fieldset with aria-describedby. */
		description?: string;
		/** Validation message from the consumer. Sets aria-invalid; the component never validates. */
		error?: string | null;
		/** Read before the error by screen readers only, for other languages. */
		errorPrefix?: string;
		/**
		 * Hold space under the options for the error, so setting it moves nothing below: true holds one
		 * line, a number holds that many. Defaults to required.
		 */
		reserveError?: boolean | number;
		/** Disable the whole group through the fieldset. */
		disabled?: boolean;
		/** Called with the new value when the person picks an option. */
		onValueChange?: (value: string) => void;
		/** Extra classes on the outer wrapper, for placement. */
		class?: string;
	}

	let {
		legend,
		options,
		name,
		value = $bindable(),
		required = false,
		requiredIndicator,
		requiredLabel = 'Required',
		optionalLabel = 'Optional',
		orientation = 'vertical',
		description,
		error = null,
		errorPrefix = 'Error:',
		reserveError,
		disabled = false,
		onValueChange,
		class: className
	}: Props = $props();

	const uid = $props.id();
	const helpId = `${uid}-help`;
	const errorId = `${uid}-error`;

	const invalid = $derived(!!error);
	const marker = $derived(requiredIndicator ?? (required ? 'required' : 'none'));
	/* A required group is the one that gets "choose one" errors, so it holds the line by default. */
	const reserveLines = $derived(
		typeof reserveError === 'number'
			? Math.max(0, reserveError)
			: (reserveError ?? required)
				? 1
				: 0
	);

	/* Each radio is described by its own description and note, then the group error. */
	function describedBy(option: RadioGroupOption, i: number) {
		const ids = [
			option.description && `${uid}-${i}-description`,
			option.meta && `${uid}-${i}-meta`,
			invalid && errorId
		].filter(Boolean);
		return ids.length ? ids.join(' ') : undefined;
	}
</script>

<div
	class={['radio-group @container min-w-0', className]}
	data-orientation={orientation}
	data-invalid={invalid ? '' : undefined}
	style:--_error-lines={reserveLines || undefined}
>
	<fieldset
		class="m-0 min-w-0 border-0 p-0"
		aria-describedby={description ? helpId : undefined}
		{disabled}
	>
		<legend class="p-0 text-sm leading-5 font-medium text-pretty break-words text-(--_ink)">
			{legend}{#if marker === 'required'}
				<!-- The native required attribute already says so to assistive technology. The en space
				     is a break point, so a marker that wraps starts on the legend's edge. -->
				<span class="text-[13px] font-normal text-(--_muted)" aria-hidden="true"
					>&ensp;{requiredLabel}</span
				>{:else if marker === 'optional'}
				<span class="text-[13px] font-normal text-(--_muted)">&ensp;{optionalLabel}</span>{/if}
		</legend>
		{#if description}
			<p id={helpId} class="mt-1 text-[13px] leading-5 text-pretty break-words text-(--_muted)">
				{description}
			</p>
		{/if}

		<div
			class={[
				'mt-1 flex flex-col',
				orientation === 'horizontal' && '@sm:flex-row @sm:flex-wrap @sm:gap-x-6'
			]}
		>
			{#each options as option, i (option.value)}
				{@const inputId = `${uid}-${i}`}
				<!-- The label's overlay makes the whole row the click target while only the label text
				     names the radio; the description and note describe it. -->
				<div
					class={[
						'radio-group__option group/option relative grid min-w-0 grid-cols-[auto_minmax(0,1fr)_auto] content-center gap-x-3 py-2',
						'text-sm leading-5 pointer-coarse:min-h-11 pointer-coarse:text-base pointer-coarse:leading-6'
					]}
				>
					<!-- An unavailable option fades its mark, label and note; its description keeps full
					     strength, because it is where the reason lives. -->
					<span class="grid h-lh place-items-center group-has-disabled/option:opacity-50">
						<span class="grid place-items-center">
							<!-- ARIA lists aria-invalid on radiogroup rather than radio, but browsers expose it
							     on a native radio and screen readers announce it on each one as focus arrives,
							     which is where the person is when they need to hear it. -->
							<!-- svelte-ignore a11y_role_supports_aria_props_implicit -->
							<input
								type="radio"
								id={inputId}
								{name}
								value={option.value}
								bind:group={value}
								disabled={option.disabled}
								{required}
								aria-describedby={describedBy(option, i)}
								aria-invalid={invalid ? 'true' : undefined}
								onchange={() => onValueChange?.(option.value)}
								class={[
									'peer col-start-1 row-start-1 m-0 size-4 cursor-pointer appearance-none rounded-full border pointer-coarse:size-5',
									'border-(--_edge) bg-(--_surface) checked:border-(--_accent) checked:bg-(--_accent)',
									'enabled:not-checked:group-hover/option:border-(--_edge-hover) enabled:not-checked:group-active/option:border-(--_accent) disabled:cursor-not-allowed',
									'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-(--_ring)',
									'transition-[border-color,background-color] duration-150 ease-(--_ease) motion-reduce:transition-none',
									'forced-colors:appearance-auto'
								]}
							/>
							<span
								class={[
									'pointer-events-none col-start-1 row-start-1 size-1.5 scale-0 rounded-full bg-(--_on-accent) pointer-coarse:size-2',
									'transition-transform duration-150 ease-(--_ease) peer-checked:scale-100 motion-reduce:transition-none',
									'forced-colors:hidden'
								]}
								aria-hidden="true"
							></span>
						</span>
					</span>
					<label
						for={inputId}
						class="min-w-0 cursor-pointer text-pretty break-words text-(--_ink) group-has-disabled/option:cursor-not-allowed group-has-disabled/option:opacity-50 after:absolute after:inset-0"
					>
						{option.label}
					</label>
					{#if option.meta}
						<span
							id="{uid}-{i}-meta"
							class="text-end whitespace-nowrap text-(--_ink) tabular-nums group-has-disabled/option:opacity-50"
						>
							{option.meta}
						</span>
					{/if}
					{#if option.description}
						<span
							id="{uid}-{i}-description"
							class="col-start-2 col-end-4 text-[13px] leading-5 text-pretty break-words text-(--_muted)"
						>
							{option.description}
						</span>
					{/if}
				</div>
			{/each}
		</div>

		{#if invalid}
			<p
				id={errorId}
				class="mt-1 flex min-h-(--_error-h) items-start gap-1 text-[13px] leading-5 text-(--_danger)"
			>
				<span class="grid h-lh shrink-0 place-items-center" aria-hidden="true">
					<svg class="size-3.5" viewBox="0 0 16 16" fill="none">
						<circle cx="8" cy="8" r="6.25" stroke="currentColor" stroke-width="1.75" />
						<path
							d="M8 4.75v3.5"
							stroke="currentColor"
							stroke-width="1.75"
							stroke-linecap="round"
						/>
						<circle cx="8" cy="11" r="1" fill="currentColor" />
					</svg>
				</span>
				<span class="min-w-0 text-pretty break-words"
					><span class="sr-only">{`${errorPrefix} `}</span>{error}</span
				>
			</p>
		{:else if reserveLines}
			<div class="mt-1 h-(--_error-h)" aria-hidden="true"></div>
		{/if}
	</fieldset>
</div>

<style>
	/* Public tokens: set --radio-group-* on the group or any ancestor to retone it. */
	.radio-group {
		--_accent: var(--radio-group-accent, #18181b);
		--_on-accent: var(--radio-group-on-accent, #ffffff);
		--_danger: var(--radio-group-danger, #b91c1c);
		--_ink: var(--radio-group-ink, #18181b);
		--_muted: var(--radio-group-muted, #52525b);
		/* 3:1 against white: the ring is the only thing marking an unselected radio (WCAG 1.4.11). */
		--_hairline: var(--radio-group-hairline, rgb(0 0 0 / 0.44));
		--_surface: var(--radio-group-surface, #ffffff);

		--_ease: cubic-bezier(0.2, 0, 0, 1);
		/* The reserved error block: reserveError's line count at the message's 20 px leading. */
		--_error-h: calc(var(--_error-lines, 0) * 1.25rem);
		--_edge: var(--_hairline);
		--_ring: var(--_accent);
		/* Hover darkens whichever edge is showing, so an invalid radio stays red under the pointer. */
		--_edge-hover: color-mix(in oklab, var(--_edge), var(--_ink) 50%);
	}

	/* Invalid: unselected radios and the focus ring carry the error colour. */
	.radio-group[data-invalid] {
		--_edge: var(--_danger);
		--_ring: var(--_danger);
	}
</style>
```

## Artifacts

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

- Artifact digest: `sha256-06a477de59130bd7e85add0d14d69c1c14abeb8133b211e475eae9edadb15c0d`
- Entry: `RadioGroup.svelte`
- Receipt: https://pagesugar.com/artifacts/cmp_radio_group_01/1.0.0/neutral/sha256-06a477de59130bd7e85add0d14d69c1c14abeb8133b211e475eae9edadb15c0d/manifest.json
- Bundle: https://pagesugar.com/artifacts/cmp_radio_group_01/1.0.0/neutral/sha256-06a477de59130bd7e85add0d14d69c1c14abeb8133b211e475eae9edadb15c0d/bundle.zip (7538 bytes, sha256 `4454bcb52cbd9ce066456694485b054e051d7a74602cc0c2cb1a197b7c0368da`)

Files:

- `RadioGroup.svelte` (entry, 10550 bytes): https://pagesugar.com/artifacts/cmp_radio_group_01/1.0.0/neutral/sha256-06a477de59130bd7e85add0d14d69c1c14abeb8133b211e475eae9edadb15c0d/source/RadioGroup.svelte

### Blue accent (`blue`)

- Artifact digest: `sha256-f5bf6f09b438c7a2ff6003dc13f9972e29a02b74c58dbabf76f9b9cfa4feaf7f`
- Entry: `RadioGroup.svelte`
- Receipt: https://pagesugar.com/artifacts/cmp_radio_group_01/1.0.0/blue/sha256-f5bf6f09b438c7a2ff6003dc13f9972e29a02b74c58dbabf76f9b9cfa4feaf7f/manifest.json
- Bundle: https://pagesugar.com/artifacts/cmp_radio_group_01/1.0.0/blue/sha256-f5bf6f09b438c7a2ff6003dc13f9972e29a02b74c58dbabf76f9b9cfa4feaf7f/bundle.zip (7544 bytes, sha256 `3183f0ab304dbe6c216319da866af29425dfdff616625c82dc52592446d9066c`)

Files:

- `RadioGroup.svelte` (entry, 10550 bytes): https://pagesugar.com/artifacts/cmp_radio_group_01/1.0.0/blue/sha256-f5bf6f09b438c7a2ff6003dc13f9972e29a02b74c58dbabf76f9b9cfa4feaf7f/source/RadioGroup.svelte
