# Radio group

> Native radios sharing a name inside a fieldset: the browser owns selection, arrow keys and form participation; drawn as round marks or, with data-variant="segmented", as one bar of segments.

Page: https://svelte-lean.vercel.app/primitives/radio-group · Markdown: https://svelte-lean.vercel.app/primitives/radio-group.md · Index: https://svelte-lean.vercel.app/llms.txt

Native radios sharing a name in a fieldset: one choice, with the arrow keys and the form value from the browser; data-variant="segmented" draws them as one bar, data-variant="swatch" as color squares.

```svelte
<script lang="ts">
	// Nothing to import: the browser owns the radios. The stylesheet is optional.
	import '@svelte-lean/styles/radio-group.css';

	let plan = $state('free');
</script>

<fieldset data-slean="radio-group">
	<legend>Plan</legend>
	<label><input type="radio" name="radio-plan" value="free" bind:group={plan} /> Free</label>
	<label><input type="radio" name="radio-plan" value="pro" bind:group={plan} /> Pro</label>
	<label>
		<input type="radio" name="radio-plan" value="team" bind:group={plan} disabled /> Team (invite only)
	</label>
</fieldset>
```

Tab lands on the checked radio; the arrow keys move the selection and skip the disabled one.

## Examples

### Segmented

```svelte
<script lang="ts">
	// Nothing to import: the browser owns the radios. The stylesheet is optional.
	import '@svelte-lean/styles/radio-group.css';

	const views = ['Day', 'Week', 'Month', 'Year'];
	let view = $state('Week');
</script>

<fieldset data-slean="radio-group" data-variant="segmented">
	<legend>View</legend>
	{#each views as value (value)}
		<label><input type="radio" name="segmented-view" {value} bind:group={view} /> {value}</label>
	{/each}
</fieldset>
```

data-variant="segmented" draws the same radios as one bar: the same value, keys and tab stop.

### Segmented, with icons

```svelte
<script lang="ts">
	import '@svelte-lean/styles/radio-group.css';

	const themes = [
		{ value: 'light', label: 'Light', icon: 'M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8zM12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4' },
		{ value: 'dark', label: 'Dark', icon: 'M20 14.5A8 8 0 0 1 9.5 4 8 8 0 1 0 20 14.5z' },
		{ value: 'system', label: 'System', icon: 'M3 5h18v11H3zM8 20h8M12 16v4' }
	];
	let theme = $state('system');
</script>

<fieldset data-slean="radio-group" data-variant="segmented">
	<legend>Theme</legend>
	{#each themes as t (t.value)}
		<label>
			<input type="radio" name="segmented-theme" value={t.value} bind:group={theme} />
			<svg viewBox="0 0 24 24" aria-hidden="true"><path d={t.icon} /></svg>
			{t.label}
		</label>
	{/each}
</fieldset>

<style>
	[data-variant='segmented'] svg {
		inline-size: 1rem;
		block-size: 1rem;
		fill: none;
		stroke: currentColor;
		stroke-width: 2;
		stroke-linecap: round;
		stroke-linejoin: round;
	}
</style>
```

### Segmented, icon only

```svelte
<script lang="ts">
	import '@svelte-lean/styles/radio-group.css';

	const alignments = [
		{ value: 'start', label: 'Align start', icon: 'M4 6h16M4 10h10M4 14h16M4 18h10' },
		{ value: 'center', label: 'Align center', icon: 'M4 6h16M7 10h10M4 14h16M7 18h10' },
		{ value: 'end', label: 'Align end', icon: 'M4 6h16M10 10h10M4 14h16M10 18h10' }
	];
	let align = $state('start');
</script>

<!-- No visible legend: aria-label names the group, and each input names its segment. -->
<fieldset data-slean="radio-group" data-variant="segmented" aria-label="Text alignment">
	{#each alignments as a (a.value)}
		<label>
			<input
				type="radio"
				name="segmented-align"
				value={a.value}
				aria-label={a.label}
				bind:group={align}
			/>
			<svg viewBox="0 0 24 24" aria-hidden="true"><path d={a.icon} /></svg>
		</label>
	{/each}
</fieldset>

<style>
	[data-variant='segmented'] svg {
		inline-size: 1rem;
		block-size: 1rem;
		fill: none;
		stroke: currentColor;
		stroke-width: 2;
		stroke-linecap: round;
		stroke-linejoin: round;
	}
</style>
```

Each icon-only segment keeps a text name in aria-label on its input.

### Segmented, switching content

```svelte
<script lang="ts">
	import '@svelte-lean/styles/radio-group.css';

	let period = $state<'monthly' | 'yearly'>('monthly');
	const price = $derived(period === 'monthly' ? 12 : 120);
</script>

<div class="plan">
	<fieldset data-slean="radio-group" data-variant="segmented" aria-label="Billing period">
		<label>
			<input type="radio" name="segmented-period" value="monthly" bind:group={period} /> Monthly
		</label>
		<label>
			<input type="radio" name="segmented-period" value="yearly" bind:group={period} /> Yearly
		</label>
	</fieldset>
	<p class="price">
		<strong>${price}</strong>
		{period === 'monthly' ? 'per month' : 'per year, two months free'}
	</p>
</div>

<style>
	.plan {
		display: grid;
		justify-items: start;
		gap: var(--slean-space-3);
	}

	.price {
		margin: 0;
		color: var(--slean-fg-muted);
	}

	.price strong {
		color: var(--slean-fg);
		font-size: var(--slean-text-lg);
	}
</style>
```

### Segmented, disabled

```svelte
<script lang="ts">
	import '@svelte-lean/styles/radio-group.css';
</script>

<div class="states">
	<!-- disabled on a radio: the segment is dimmed and the arrow keys skip it. -->
	<fieldset data-slean="radio-group" data-variant="segmented">
		<legend>Export as</legend>
		<label><input type="radio" name="segmented-format" value="csv" checked /> CSV</label>
		<label><input type="radio" name="segmented-format" value="json" /> JSON</label>
		<label><input type="radio" name="segmented-format" value="pdf" disabled /> PDF</label>
	</fieldset>

	<!-- disabled on the fieldset: the whole bar. -->
	<fieldset data-slean="radio-group" data-variant="segmented" disabled>
		<legend>Density</legend>
		<label><input type="radio" name="segmented-density" value="compact" /> Compact</label>
		<label>
			<input type="radio" name="segmented-density" value="comfortable" checked /> Comfortable
		</label>
	</fieldset>
</div>

<style>
	.states {
		display: flex;
		flex-wrap: wrap;
		gap: var(--slean-space-6);
	}
</style>
```

### Swatches

```svelte
<script lang="ts">
	import '@svelte-lean/styles/radio-group.css';

	const swatches = [
		{ hex: '#ef4444', name: 'Red' },
		{ hex: '#f97316', name: 'Orange' },
		{ hex: '#eab308', name: 'Yellow' },
		{ hex: '#22c55e', name: 'Green' },
		{ hex: '#06b6d4', name: 'Cyan' },
		{ hex: '#3b82f6', name: 'Blue' },
		{ hex: '#8b5cf6', name: 'Violet' },
		{ hex: '#ec4899', name: 'Pink' },
		{ hex: '#64748b', name: 'Slate' },
		{ hex: '#ffffff', name: 'White' },
		{ hex: '#171717', name: 'Black' }
	];
	let color = $state('#3b82f6');
</script>

<fieldset data-slean="radio-group" data-variant="swatch">
	<legend>Label color</legend>
	{#each swatches as swatch (swatch.hex)}
		<label style="--slean-swatch: {swatch.hex}">
			<input type="radio" name="label-color" value={swatch.hex} aria-label={swatch.name} bind:group={color} />
		</label>
	{/each}
</fieldset>
```

data-variant="swatch" draws the radios as color squares; each input is named by aria-label. The same radios: one tab stop, arrow keys, the form value.

### Any color

```svelte
<script lang="ts">
	import '@svelte-lean/styles/input.css';
	import '@svelte-lean/styles/radio-group.css';

	const swatches = [
		{ hex: '#ef4444', name: 'Red' },
		{ hex: '#f97316', name: 'Orange' },
		{ hex: '#eab308', name: 'Yellow' },
		{ hex: '#22c55e', name: 'Green' },
		{ hex: '#06b6d4', name: 'Cyan' },
		{ hex: '#3b82f6', name: 'Blue' },
		{ hex: '#8b5cf6', name: 'Violet' },
		{ hex: '#ec4899', name: 'Pink' },
		{ hex: '#64748b', name: 'Slate' },
		{ hex: '#ffffff', name: 'White' },
		{ hex: '#171717', name: 'Black' }
	];

	// One value for the three ways to choose: a swatch, the browser's picker, a typed code.
	let color = $state('#22c55e');
	let typed = $state('#22c55e');
	const isHex = (text: string) => /^#[0-9a-f]{6}$/i.test(text);
	const named = $derived(swatches.find((swatch) => swatch.hex === color)?.name ?? 'Custom');

	function choose(next: string) {
		color = next.toLowerCase();
		typed = color;
	}
</script>

<div class="picker">
	<input
		type="color"
		data-slean="input"
		aria-label="Any color"
		value={color}
		oninput={(event) => choose(event.currentTarget.value)}
	/>
	<label>
		Hex code
		<input
			data-slean="input"
			value={typed}
			maxlength="7"
			spellcheck="false"
			aria-invalid={isHex(typed) ? undefined : 'true'}
			oninput={(event) => {
				typed = event.currentTarget.value.trim();
				if (isHex(typed)) color = typed.toLowerCase();
			}}
		/>
	</label>
	<output>{named}</output>
</div>

<fieldset data-slean="radio-group" data-variant="swatch" aria-label="Palette">
	{#each swatches as swatch (swatch.hex)}
		<label style="--slean-swatch: {swatch.hex}">
			<input
				type="radio"
				name="tag-color"
				value={swatch.hex}
				aria-label={swatch.name}
				checked={swatch.hex === color}
				onchange={() => choose(swatch.hex)}
			/>
		</label>
	{/each}
</fieldset>
```

Three inputs write one value: a swatch, the native color input, or a typed code. A color outside the palette checks no swatch.

### Looks

```svelte
<script lang="ts">
	import '@svelte-lean/styles/radio-group.css';

	const variants = [undefined, 'segmented'];
	const looks = [{ size: 'small' }, { size: 'medium' }, { size: 'large' }, { color: 'secondary' }, { color: 'success' }];
</script>

<!-- data-size: small, medium (the default) and large; data-color: the checked color. -->
{#each variants as variant}
	<div class="row">
		{#each looks as { size, color }}
			{@const name = `look-${variant ?? 'radio'}-${size ?? color}`}
			<fieldset data-slean="radio-group" data-slean-orientation="horizontal"
				data-variant={variant} data-size={size} data-color={color}>
				<legend>{size ?? color}</legend>
				<label><input type="radio" {name} value="on" checked /> On</label>
				<label><input type="radio" {name} value="off" /> Off</label>
			</fieldset>
		{/each}
	</div>
{/each}
```

Round marks (the default) or data-variant="segmented"; small, medium and large; data-color is the checked color.

### Horizontal

```svelte
<script lang="ts">
	// Nothing to import: the browser owns the radios. The stylesheet is optional.
	import '@svelte-lean/styles/radio-group.css';

	const sizes = ['XS', 'S', 'M', 'L', 'XL'];
	let size = $state('M');
</script>

<fieldset data-slean="radio-group" data-slean-orientation="horizontal">
	<legend>Size</legend>
	{#each sizes as value (value)}
		<label><input type="radio" name="radio-size" {value} bind:group={size} /> {value}</label>
	{/each}
</fieldset>
```

### With descriptions

```svelte
<script lang="ts">
	// Nothing to import: the browser owns the radios. The stylesheet is optional.
	import '@svelte-lean/styles/radio-group.css';

	const options = [
		{ value: 'standard', title: 'Standard', detail: 'Four to six business days', price: 'Free' },
		{ value: 'express', title: 'Express', detail: 'Two business days', price: '$9' },
		{ value: 'overnight', title: 'Overnight', detail: 'Next business day', price: '$24' }
	];
	let shipping = $state('standard');
</script>

<fieldset data-slean="radio-group" class="options">
	<legend>Shipping</legend>
	{#each options as option (option.value)}
		<label>
			<input type="radio" name="radio-shipping" value={option.value} bind:group={shipping} />
			<span class="text">
				<span>{option.title}</span>
				<small>{option.detail}</small>
			</span>
			<span class="price">{option.price}</span>
		</label>
	{/each}
</fieldset>

<style>
	.options {
		max-inline-size: 24rem;
	}

	.options label {
		padding: var(--slean-space-3);
		border: 1px solid var(--slean-border);
		border-radius: var(--slean-radius-md);
	}

	.options label:has(:checked) {
		border-color: var(--slean-primary-main);
	}

	.text {
		display: grid;
		flex: 1;
	}

	.text small {
		color: var(--slean-fg-muted);
		font-size: var(--slean-text-xs);
	}
</style>
```

The whole card is the label, so a click anywhere on it checks the radio.

### In a form

```svelte
<script lang="ts">
	// Nothing to import: the browser owns the radios. The stylesheet is optional.
	import '@svelte-lean/styles/radio-group.css';
	import '@svelte-lean/styles/button.css';

	let sent = $state('');

	function submit(event: SubmitEvent) {
		event.preventDefault();
		sent = String(new FormData(event.currentTarget as HTMLFormElement).get('contact'));
	}
</script>

<!-- required on one radio makes the group required; :user-invalid marks it after a submit. -->
<form class="form" onsubmit={submit}>
	<fieldset data-slean="radio-group">
		<legend>How should we contact you?</legend>
		<label><input type="radio" name="contact" value="email" required /> Email</label>
		<label><input type="radio" name="contact" value="phone" /> Phone</label>
		<label><input type="radio" name="contact" value="none" /> Do not contact me</label>
	</fieldset>
	<button type="submit" data-slean="button">Continue</button>
	{#if sent}<p role="status">Submitted: {sent}</p>{/if}
</form>

<style>
	.form {
		display: grid;
		justify-items: start;
		gap: var(--slean-space-4);
	}

	.form p {
		margin: 0;
	}
</style>
```

Submit without a choice: the browser asks for one and the radios turn to the error color.

### Disabled

```svelte
<script lang="ts">
	// Nothing to import: the browser owns the radios. The stylesheet is optional.
	import '@svelte-lean/styles/radio-group.css';
</script>

<!-- disabled on the fieldset disables every radio in it. -->
<fieldset data-slean="radio-group" disabled>
	<legend>Billing cycle (locked by your contract)</legend>
	<label><input type="radio" name="radio-billing" value="monthly" /> Monthly</label>
	<label><input type="radio" name="radio-billing" value="yearly" checked /> Yearly</label>
</fieldset>
```

## Usage

Plain markup with a stylesheet: install `@svelte-lean/styles`. No JavaScript runs.

```bash
npm install @svelte-lean/styles
```

`stylesheets`

```ts
import '@svelte-lean/styles/radio-group.css';
```

Put `data-slean="radio-group"` on a `<fieldset>` with a `<legend>`, and one `<label>` per option holding a radio; give every radio the same `name` and, in most forms, check a default. Bind the value with `bind:group`, read it from `FormData`, or listen to the native `change` event.

When a fieldset is impossible, a container with `role="radiogroup"` and `aria-labelledby` is the alternative. A choice among many options is a Select.

`without a fieldset`

```html
<!-- When a fieldset is impossible (inside a table cell, for example): -->
<div role="radiogroup" aria-labelledby="plan-label">
	<span id="plan-label">Plan</span>
	<label><input type="radio" name="plan" value="free" checked /> Free</label>
	<label><input type="radio" name="plan" value="pro" /> Pro</label>
</div>
```

## Anatomy

| Part | Element | data-slean-part | Required | Notes |
| --- | --- | --- | --- | --- |
| root | `<fieldset>` | – | yes | data-slean="radio-group" marks it for the styles; data-variant="segmented" draws one bar, data-variant="swatch" a grid of color squares; data-slean-orientation="horizontal" one row. A container with role="radiogroup" and aria-labelledby is the alternative. |
| legend | `<legend>` | – | yes | The accessible name of the group. |
| radio | `<label><input type="radio" name="…" value="…"> …</label>` | – | yes | Radios share one name. Render a checked default in most forms. |
| swatch | `<label style="--slean-swatch: #ef4444"><input type="radio" aria-label="Red"></label>` | – | no | With data-variant="swatch": the label carries the color, the input its name (aria-label) and the submitted value. |

## Keyboard

| Key | When | Result |
| --- | --- | --- |
| `ArrowDown`/`ArrowRight` | focus inside the group | Checks the next radio, wrapping (native) |
| `ArrowUp`/`ArrowLeft` | focus inside the group | Checks the previous radio, wrapping (native) |
| `Space` | focus on an unchecked radio | Checks it (native) |
| `Tab`/`Shift+Tab` | anywhere | Enters and leaves the group as one stop, on the checked radio (native) |

## Styling

`radio-group.css` lays out the fieldset as a column (or a wrapping row with `data-slean-orientation="horizontal"`), styles the legend and labels, and draws the round mark with `appearance: none` and a dot on `::before`; an invalid group takes the danger color, and disabled radios, their labels and a disabled fieldset are dimmed. The swatch look paints each label with its `--slean-swatch`, draws the check icon in `contrast-color()` of the swatch (or on a surface disc where that function is missing) and rings the checked square. The tokens it reads:

| Token | Default (light) | Applies to |
| --- | --- | --- |
| `--slean-space-2` | `calc(var(--slean-spacing) * 2)` | gap, margin-block-end |
| `--slean-space-4` | `calc(var(--slean-spacing) * 4)` | column-gap, padding-inline |
| `--slean-space-1` | `calc(var(--slean-spacing) * 1)` | margin-block-end |
| `--slean-fg` | `var(--slean-neutral-12)` | color, box-shadow |
| `--slean-text-sm` | `0.875rem` | font-size |
| `--slean-font-weight-medium` | `500` | font-weight |
| `--slean-control-border` | `var(--slean-border-strong)` | border |
| `--slean-radius-full` | `9999px` | border-radius |
| `--slean-control-bg` | `var(--slean-surface)` | background |
| `--slean-color-contrast-text` | `var(--slean-primary-contrast-text)` | color |
| `--slean-duration-fast` | `100ms` | transition |
| `--slean-ease` | `cubic-bezier(0.2, 0, 0, 1)` | transition |
| `--slean-color-main` | `var(--slean-primary-main)` | border-color, background, color |
| `--slean-error-main` | `oklch(52% 0.19 25)` | border-color |
| `--slean-control-border-disabled` | `var(--slean-border)` | border-color |
| `--slean-control-bg-disabled` | `var(--slean-muted)` | background |
| `--slean-fg-muted` | `var(--slean-neutral-11)` | color |
| `--slean-control-height-md` | `2.25rem` | min-block-size, grid-template-columns, inline-size, block-size, inset |
| `--slean-muted` | `var(--slean-neutral-3)` | background |
| `--slean-radius-md` | `var(--slean-radius)` | border-start-start-radius, border-end-start-radius, border-start-end-radius, border-end-end-radius |
| `--slean-control-height-sm` | `2rem` | min-block-size |
| `--slean-space-3` | `calc(var(--slean-spacing) * 3)` | padding-inline |
| `--slean-control-height-lg` | `2.75rem` | min-block-size |
| `--slean-space-5` | `calc(var(--slean-spacing) * 5)` | padding-inline |
| `--slean-text-md` | `1rem` | font-size |
| `--slean-radius-sm` | `calc(var(--slean-radius) * 0.6)` | border-radius |
| `--slean-surface` | `oklch(100% 0 0)` | background, box-shadow |
| `--slean-border` | `var(--slean-neutral-6)` | box-shadow |
| `--slean-elevation-1` | `0 1px 2px oklch(0% 0 0 / 0.08)` | box-shadow |
| `--slean-focus-ring` | `var(--slean-focus-ring-width) solid var(--slean-focus-ring-color)` | outline |
| `--slean-control-height-xs` | `1.75rem` | grid-template-columns, inline-size, block-size |
| `--slean-swatch` | `transparent` | background, color |
| `--slean-icon-check` | `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M3.5 8.5l3 3 6-7' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' fill='none'/%3E%3C/svg%3E")` | mask |
| `--slean-focus-ring-offset` | `2px` | outline-offset |

State selectors the stylesheet targets, all from the platform or ARIA: `:checked`, `:disabled`, `:first-of-type`, `:focus-visible`, `:hover`, `:last-of-type`, `:user-invalid`, `[aria-invalid="true"]`, `[type="radio"]`.

Variant attributes: `data-slean-orientation` (`horizontal`); `data-variant` (`segmented`, `swatch`); `data-size` (`small`, `large`); `data-full-width`.

## Under the hood

Runtime and size

- **Tier**: 0 · Native
- **Behavior JS**: 0 B brotli · 0 B gzip · `fixtures/results.json (native-only)` · [method](https://svelte-lean.vercel.app/performance/methodology.md)
- **Platform features**: `<fieldset>`, `<legend>`, `<input type="radio">`, `name`
- **Shared listeners**: none
- **Per-instance listeners**: none
- **Lazy state**: none
- **Native base**: `<fieldset> + <input type="radio" name="…">`

Tier 0: `packages/primitives/src/radio-group` has no behavior, validate or register module, the Vite plugin maps `radio-group` (and the native name `radio`) to no module, and the playground’s bundle spec asserts that the native page, which includes this markup, loads no behavior runtime.

Why this implementation

A radio group is the one native control that already has roving focus: the group is a single tab stop on the checked radio, all four arrow keys move the selection and wrap, disabled radios are skipped, and the form submits the value. A `<div role="radio">` set re-creates the arrow-key logic in JavaScript and loses the form.

Svelte Lean therefore ships a stylesheet that draws the round mark and lays out the fieldset, a contract that names the alternatives, and no arrow-key runtime and no value store.

### The browser owns

- selection: at most one checked radio per name
- arrow-key movement inside the group, wrapping, and the single tab stop
- form participation and the change event
- the group name through \<legend\>

### Svelte Lean owns

- radio-group.css: fieldset layout, the drawn radio mark, three sizes, disabled presentation
- the segmented look (data-variant="segmented"): one bar of equal segments, the checked one raised, the focus ring on the segment
- the swatch look (data-variant="swatch"): a grid of color squares from --slean-swatch, the check icon in contrast-color() or on a surface disc, the ring on the checked one
- the contract and documentation

Accessibility

- Radios with the same `name` inside one `<fieldset>` with a `<legend>`, which is the group’s accessible name. Each radio has its own label.
- At most one radio is `checked`. No `aria-checked`; the native state is exposed.
- Focus is native roving: the checked radio, or the first one when none is checked, is the group’s tab stop.
- `disabled` on an input removes it from arrow navigation; `disabled` on the fieldset disables the whole group. Arrow-key direction under RTL is defined by the browser.

Platform features

| Feature | Baseline | Outside the target |
| --- | --- | --- |
| `<fieldset>, <legend>, <input type="radio">` | Widely available | Not applicable within the support policy |
| `contrast-color() (the swatch check)` | Safari 26, Firefox 146, Chromium | The check is drawn in the text color on a surface disc |

Without JavaScript

Fully functional. The playground’s native page runs the arrow-key and single-tab-stop assertions with page JavaScript disabled.

Server rendering and hydration

The `checked` attribute renders the initial value. No id is required unless `for` or `aria-labelledby` is used, and then it is authored.

The radios are interactive as soon as the document is parsed; hydration attaches nothing to them.

Compatibility

Every feature the radio group depends on is Baseline widely available. The segmented and swatch looks mark the checked option through `:has()` (Baseline 2023); the swatch check takes `contrast-color()` where it exists (Safari 26, Firefox 146) and sits on a surface disc elsewhere.

Tests and source

- `apps/playground/tests/primitives/native.spec.ts` Playwright, with page JavaScript enabled and disabled
- `apps/playground/tests/primitives/a11y.spec.ts` axe on every playground page and on open dialog, popover and menu states
- `apps/playground/tests/primitives/nav.spec.ts` Playwright: groups, the current page, link tabs, the rail with hints and hover cards, the scroll target group, with axe
- `fixtures/native-only` consumer build asserting that no behavior runtime ships (invariant A)

- `packages/primitives/src/radio-group/contract.md` the contract in prose: semantics, keyboard, focus, ARIA, SSR, JS-disabled behavior
- `packages/primitives/src/radio-group/contract.ts` typed constants: name, tier, base, parts, options, events
- `packages/styles/css/radio-group.css` the optional stylesheet
