Primitives Forms
Radio group
Native <input type="radio"> controls sharing a name inside a <fieldset>. The browser owns selection, arrow-key movement and form participation; the package ships the stylesheet, the contract, and no runtime. Tier 0.
- Tier
- 0 · Native
- Behavior JS
- 0 B brotli · 0 B gzip ·
fixtures/· methodresults.json (native-only) - 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="…">
On this page
Example
<fieldset data-slean="radio-group">
<legend>Plan</legend>
<label><input type="radio" name="plan" value="free" checked /> Free</label>
<label><input type="radio" name="plan" value="pro" /> Pro</label>
<label><input type="radio" name="plan" value="team" disabled /> Team (invite only)</label>
</fieldset><script lang="ts">
// Nothing to import: the browser owns the radios. The stylesheet is optional.
import '@svelte-lean/styles/radio-group.css';
</script>
<fieldset data-slean="radio-group">
<legend>Plan</legend>
<label><input type="radio" name="plan" value="free" checked /> Free</label>
<label><input type="radio" name="plan" value="pro" /> Pro</label>
<label><input type="radio" name="plan" value="team" disabled /> Team (invite only)</label>
</fieldset>Why this implementation exists
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, disabled presentation
- the contract
- documentation
Usage
The markup needs no package. Install @svelte-lean/styles for the stylesheet; @svelte-lean/primitives adds the typed contract and nothing at runtime.
npm install @svelte-lean/stylespnpm add @svelte-lean/stylesyarn add @svelte-lean/stylesbun add @svelte-lean/stylesimport '@svelte-lean/styles/tokens.css';
import '@svelte-lean/styles/base.css';
import '@svelte-lean/styles/radio-group.css';Use a radio group for one choice among a few visible options. Render a checked default in most
forms; a group with nothing checked is reachable on its first radio. When a fieldset is
impossible, a container with role="radiogroup" and aria-labelledby is
the alternative. A choice among many options is a native <select>.
<!-- 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. 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. |
Runtime profile
The runtime block reads the tier and the events from the contract and the bytes from the
native-only consumer fixture: a production Vite build with the Vite plugin whose module graph
contains no @svelte-lean/core or @svelte-lean/primitives module. The
fixture's markup is button, dialog and popover; the radio path is proven the same way by
construction: packages/primitives/src/radio-group has no behavior, validate or
register module, the 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. Nothing is attached at hydration.
Accessibility contract
- Radios with the same
nameinside one<fieldset>with a<legend>, which is the group's accessible name. Each radio has its own label. - At most one radio is
checked. Noaria-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.
disabledon an input removes it from arrow navigation;disabledon the fieldset disables the whole group. Arrow-key direction under RTL is defined by the browser; the package changes nothing.
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) |
Platform features
| Feature | Baseline | Outside the target |
|---|---|---|
<fieldset>, <legend>, <input type="radio"> | Widely available | Not applicable within the support policy |
Without JavaScript
Fully functional. The playground's native page runs the arrow-key and single-tab-stop assertions with page JavaScript disabled.
Server rendering
The checked attribute renders the initial value. No id is required unless for or aria-labelledby is used, and then it is authored.
Before hydration
The radios are interactive as soon as the document is parsed; the native page's assertions run with JavaScript disabled, which covers the interval before any script as well. Hydration attaches nothing to inputs the package does not bind.
Styling
radio-group.css lays out the fieldset as a column, styles the legend and labels,
and draws the round mark on the radios with appearance: none and a dot on ::before; disabled radios and the labels that contain them are dimmed, and a
disabled fieldset dims the whole group. The tokens it reads and the states it targets:
| Token | Default (light) | Applies to |
|---|---|---|
--slean-space-2 | 0.5rem | gap |
--slean-space-4 | 1rem | column-gap |
--slean-space-1 | 0.25rem | margin-block-end |
--slean-fg | var(--slean-neutral-12) | color |
--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-accent-fg | oklch(100% 0 0) | color |
--slean-duration-fast | 100ms | transition |
--slean-ease | cubic-bezier(0.2, 0, 0, 1) | transition |
--slean-accent | oklch(54% 0.19 258) | border-color, background |
--slean-danger | oklch(55% 0.2 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-border-hover | var(--slean-accent) | border-color |
--slean-control-height-md | 2.25rem | inset |
State selectors the stylesheet targets, all from the platform or ARIA: :checked, :disabled, :hover, :user-invalid, [aria-invalid="true"], [type="radio"].
Variant attributes: data-slean-orientation (horizontal).
Controlled integration
The checked radio is the state. Svelte reads and writes it with bind:group; FormData reads it on submit and the native change event reports a
change. The package mirrors nothing and dispatches no slean:* event for a radio group;
a Svelte adapter is not built and is not needed for this primitive.
<script lang="ts">
// The checked radio is the state. bind:group reads and writes it; FormData reads it on submit.
let plan = $state('free');
</script>
<fieldset data-slean="radio-group">
<legend>Plan</legend>
<label><input type="radio" name="plan" value="free" bind:group={plan} /> Free</label>
<label><input type="radio" name="plan" value="pro" bind:group={plan} /> Pro</label>
</fieldset>
<p>Selected: {plan}</p>Compatibility notes
Every feature the radio group depends on is Baseline widely available; there is no fallback case. The policy is on Browser support.
Testing
apps/playground/tests/primitives/native.spec.tsPlaywright, with page JavaScript enabled and disabledapps/playground/tests/primitives/a11y.spec.tsaxe on every playground page and on open dialog, popover and menu statesfixtures/native-onlyconsumer build asserting that no behavior runtime ships (invariant A)
Source
packages/primitives/src/radio-group/contract.mdthe contract in prose: semantics, keyboard, focus, ARIA, SSR, JS-disabled behaviorpackages/primitives/src/radio-group/contract.tstyped constants: name, tier, base, parts, options, eventspackages/styles/css/radio-group.cssthe optional stylesheet