Skip to content
Download ZIP

Blue accent palette · 7.4 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_radio_group_01 · version 1.0.0 · Blue accent palette
Using the PageSugar MCP server, fetch component cmp_radio_group_01 version 1.0.0 with variant "blue", 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
Blue accent
Version
1.0.0
Digest
Full digest
sha256-f5bf6f09b438c7a2ff6003dc13f9972e29a02b74c58dbabf76f9b9cfa4feaf7f
RadioGroup.svelte Svelte · 10.3 KB Raw
<!--
	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, #1d4ed8);
		--_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>

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.

Suggested location
src/lib/components/radio-group-01
Required props
legendoptionsname

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.

Example

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>

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.

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 and content inputs#

On this page
NameTypeRequiredDefaultDescription
legendstringYesNoneThe question or group label, rendered as the fieldset's legend.
optionsRadioGroupOption[]YesNoneThe 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.
namestringYesNoneShared input name, so native submission includes the chosen value.
valuestring | undefinedNoNoneBindable selected value. Undefined leaves every option unselected.
requiredbooleanNofalseNative required on every radio, so an empty group blocks submission. Shows the Required marker unless requiredIndicator says otherwise.
requiredIndicator'required' | 'optional' | 'none'NoNoneWhich marker follows the legend. Defaults to 'required' for required groups and 'none' otherwise.
requiredLabelstringNo'Required'Marker text for required groups.
optionalLabelstringNo'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.
descriptionstringNoNoneHelp text under the legend, linked to the fieldset with aria-describedby.
errorstring | nullNonullValidation 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.
errorPrefixstringNo'Error:'Visually hidden text read before the error.
reserveErrorboolean | numberNoNoneHold 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.
disabledbooleanNofalseDisable the whole group through the fieldset. Disabled radios leave the tab order and do not submit.
onValueChange(value: string) => voidNoNoneCalled with the new value when the person picks an option.
classstringNoNoneExtra classes on the outer wrapper, for placement.

Customization#

On this page

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.

Public CSS variables

VariableToken
--radio-group-accentaccent
--radio-group-on-accentonAccent
--radio-group-dangerdanger
--radio-group-inkink
--radio-group-mutedmuted
--radio-group-hairlinehairline
--radio-group-surfacesurface

Accessibility#

On this page
  • 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.

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.