Primitives Forms
Segmented control
Radio inputs in a fieldset, drawn as the segments of one bar. The browser owns the single choice, the arrow keys, the single tab stop and the form value; the package adds a stylesheet and a contract. Tier 0.
- Tier
- 0 · Native
- Behavior JS
- 0 B brotli · 0 B gzip ·
fixtures/· methodresults.json (native-only) - Platform features
<input type="radio">,<fieldset>,<legend>,:has(),:focus-visible- Shared listeners
- none
- Per-instance listeners
- none
- Lazy state
- none
- Native base
<fieldset> of <input type="radio"> sharing a name
On this page
Example
<fieldset data-slean="segmented">
<legend>View</legend>
<label><input type="radio" name="segmented-view" value="day" /> Day</label>
<label><input type="radio" name="segmented-view" value="week" checked /> Week</label>
<label><input type="radio" name="segmented-view" value="month" /> Month</label>
<label><input type="radio" name="segmented-view" value="year" /> Year</label>
</fieldset><script lang="ts">
// Nothing to import: the browser owns the radios. The stylesheet is optional.
import '@svelte-lean/styles/segmented.css';
const views = ['day', 'week', 'month', 'year'] as const;
let view = $state<(typeof views)[number]>('week');
</script>
<fieldset data-slean="segmented">
<legend>View</legend>
{#each views as value (value)}
<label>
<input type="radio" name="segmented-view" {value} bind:group={view} />
{value[0].toUpperCase() + value.slice(1)}
</label>
{/each}
</fieldset>Why this implementation exists
A segmented control is a radio group with another look: one choice among a few, one tab stop, the arrow keys to move, a value in the form. Radios sharing a name do all of that natively, with the group named by its <legend> and each segment named by its label.
The stylesheet draws the labels as equal-width segments of one bar. Each radio covers its segment, transparent and focusable, so a click anywhere on the segment checks it; label:has(input:checked) raises the checked segment and label:has(input:focus-visible) draws the keyboard ring on it. There is no element to keep in sync, because the checked state is the radio’s.
The browser owns
- the single choice among radios with one name
- the arrow keys, wrapping and skipping disabled radios, and the single tab stop
- the group name from <legend> and each segment’s name from its label
- the form value and the change event
Svelte Lean owns
- segmented.css: equal-width segments in one bar, the raised checked segment, the focus ring on the segment, disabled segments
- 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/segmented.css';Put data-slean="segmented" on a <fieldset> with a <legend>, and one <label> per choice holding a radio and its text. Give every radio the same name and check one.
Bind the value with bind:group, read it from FormData, or listen to the native change event.
Use it for two to five short choices that take effect at once or are submitted with a form. For panels of content use Tabs; for buttons that are pressed independently use Toggle group.
Anatomy
| Part | Element | data-slean-part | Required | Notes |
|---|---|---|---|---|
| root | <fieldset data-slean="segmented"> | – | yes | Named by its <legend>, or by aria-label when the bar has no visible label. |
| segment | <label><input type="radio" name="…" value="…"> Text</label> | – | yes | Two to five, one checked. The radio covers the label, transparent and focusable. |
Runtime profile
Tier 0: the segmented control has no behavior module, the Vite plugin maps segmented to no module, and the native-only fixture proves the path ships no Svelte Lean JavaScript.
Accessibility contract
- The fieldset is a group named by its legend, or by
aria-labelwhen the bar has no visible label; each radio is named by its label. - The group is one tab stop: the checked radio. The arrow keys check the next or previous radio, wrap, and skip disabled ones.
- The focus ring is drawn on the segment of the focused radio, only for keyboard focus (
:focus-visible). - An icon-only segment keeps a text name:
aria-labelon its input, the iconaria-hidden. - Forced colors: the checked segment takes the Highlight system color and every segment an outline.
Keyboard
| Key | When | Result |
|---|---|---|
| Tab | on the page | Enters the group at the checked radio; the next Tab leaves it (native) |
| ArrowRight/ArrowDown | focus in the group | Checks the next segment, wrapping (native; mirrored under RTL in Chrome) |
| ArrowLeft/ArrowUp | focus in the group | Checks the previous segment, wrapping (native) |
| Space | focus on an unchecked radio | Checks it (native) |
Platform features
| Feature | Baseline | Outside the target |
|---|---|---|
Radios, <fieldset>, <legend> | Widely available | Not applicable |
:has() | Baseline 2023 (Chrome 105, Safari 15.4, Firefox 121) | The checked segment is not drawn raised; the radios keep working |
Without JavaScript
Fully functional.
Server rendering
The checked attribute renders the initial choice; the raised segment is drawn by CSS from it, before any script runs.
Before hydration
Nothing is attached; the radios work before and after hydration alike.
Styling
segmented.css lays the labels out as equal-width grid columns on a muted bar, raises the checked segment with a surface, a hairline and a small shadow (a ::before inset behind the text), draws the focus ring on that surface, and dims disabled segments. The tokens it reads:
| Token | Default (light) | Applies to |
|---|---|---|
--slean-space-2 | 0.5rem | margin-block-end, gap |
--slean-fg | var(--slean-neutral-12) | color |
--slean-text-sm | 0.875rem | font-size |
--slean-font-weight-medium | 500 | font-weight |
--slean-control-height-md | 2.25rem | min-block-size |
--slean-space-4 | 1rem | padding-inline |
--slean-muted | var(--slean-neutral-3) | background |
--slean-fg-muted | var(--slean-neutral-11) | color |
--slean-duration-fast | 100ms | transition |
--slean-ease | cubic-bezier(0.2, 0, 0, 1) | transition |
--slean-radius-md | 0.625rem | border-start-start-radius, border-end-start-radius, border-start-end-radius, border-end-end-radius |
--slean-radius-sm | 0.375rem | border-radius |
--slean-surface | oklch(100% 0 0) | background |
--slean-border | var(--slean-neutral-6) | box-shadow |
--slean-shadow-sm | 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 |
State selectors the stylesheet targets, all from the platform or ARIA: :checked, :disabled, :first-of-type, :focus-visible, :hover, :last-of-type, [type="radio"].
Compatibility notes
Radios and fieldsets are widely available. The checked, focused and disabled segments are selected with :has(), Baseline 2023; where it is missing the radios work and the segments do not show the choice.
Examples
Disabled segments and a hidden label
disabled on a radio dims its segment and the arrow keys skip it; disabled on the fieldset disables the bar. Without a visible legend, the fieldset
is named by aria-label.
<fieldset data-slean="segmented" aria-label="Alignment">
<label><input type="radio" name="segmented-align" value="start" checked /> Start</label>
<label><input type="radio" name="segmented-align" value="center" /> Center</label>
<label><input type="radio" name="segmented-align" value="end" disabled /> End</label>
</fieldset>
<fieldset data-slean="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>Testing
apps/playground/tests/primitives/forms.spec.tsPlaywright: keyboard, form values, :user-invalid, names and descriptions, axepackages/styles/testsstatic checks of the stylesheet (layers, tokens, specificity, dark parity)fixtures/native-onlyconsumer build asserting that no behavior runtime ships (invariant A)
Source
packages/primitives/src/segmented/contract.mdthe contract in prose: semantics, keyboard, focus, ARIA, SSR, JS-disabled behaviorpackages/primitives/src/segmented/contract.tstyped constants: name, tier, base, parts, options, eventspackages/styles/css/segmented.cssthe optional stylesheet