Segmented Control
A grid of native <input type="radio"> styled via class + data attributes from the optional styles layer. No JS, no per-N rules, no segment cap. The indicator is two-tier: an opacity crossfade per label as the universal baseline, with a single sliding indicator powered by CSS Anchor Positioning wherever it’s supported.
Install
import "elements-kit/ui/styles/theme.css";import "elements-kit/ui/styles/scaling.css";import "elements-kit/ui/styles/radius.css";import "elements-kit/ui/styles/space.css";import "elements-kit/ui/styles/typography.css";import "elements-kit/ui/styles/cursor.css";import "elements-kit/ui/styles/unset.css";// pick a neutral palette for --neutral-* (must match data-neutral on root):import "elements-kit/ui/styles/palette/gray.css";import "elements-kit/ui/styles/neutral/gray.css";// import any color scales you want to use for accent theming:import "elements-kit/ui/styles/palette/mint.css";import "elements-kit/ui/styles/accent/mint.css";// and the segmented control itself:import "elements-kit/ui/segmented-control/segmented-control.css";See Styles for the full token system, accent/gray scale lists, and theming knobs.
API
<div class="unset x-segmented-control" role="radiogroup" aria-label="View" data-variant="surface" data-size="2"> <label> <input type="radio" name="view" value="grid" checked /> <span>Grid</span> </label> <label> <input type="radio" name="view" value="list" /> <span>List</span> </label> <label> <input type="radio" name="view" value="kanban" /> <span>Kanban</span> </label></div>The unset class (Styles → Unset native styles) clears the browser’s default rendering on the container and the radios so .x-segmented-control styles render predictably.
Markup contract: the container holds <label> direct children, each wrapping one <input type="radio"> and its visible text. CSS visually hides the input (the label is the hit target) and renders the indicator behind it.
| Attribute (on the container) | Values |
|---|---|
data-size | 1, 2 (default), 3 |
data-variant | surface (default), soft |
data-accent (on a parent or the container) | any imported color scale (mint, blue, iris, …) |
data-high-contrast | modifier — boosts active text against indicator |
data-radius (on a parent) | inherits from the Styles radius scale |
data-disabled | non-fieldset containers; on a <fieldset> use the native disabled attribute |
| Attribute (on each input) | Notes |
|---|---|
type="radio" | required |
name | required, shared across siblings in one group |
value | required for form submission |
checked | preselect one — recommended (see Accessibility) |
disabled | disables a single segment; arrow-key nav skips it automatically |
ARIA attributes (role, aria-label / aria-labelledby) are author-supplied — the CSS adds nothing.
Sizing
data-size | height | radius |
|---|---|---|
1 | --space-5 (~24px) | --radius-1 |
2 (default) | --space-6 (~32px) | --radius-2 |
3 | --space-7 (~40px) | --radius-3 |
At sizes ≥ 2 the track meets the WCAG 2.5.5 target-size minimum for pointer hit-areas.
Variants
surface(default) — surface track tinted with--neutral-a3, indicator fills with--color-backgroundand a 1px hairline + 1px shadow. Inactive text uses--neutral-a11; active jumps to--neutral-12and--font-weight-medium.soft— accent-tinted track using--accent-a3. Inactive text uses--accent-a11; active uses--accent-12. Pairs well withdata-accent.
States
:checked— surfaces the indicator (crossfade in Tier 1; slide in Tier 2) and bumps text weight + letter-spacing for clarity without relying on color alone.:focus-visible— 2px--focus-8ring on the label (not the hidden input).:hover— inactive segments get a subtle--neutral-a2wash; active and disabled don’t react.:disabled/[data-disabled]— desaturates colors, drops the shadow, and switches cursor to--cursor-disabled.
Theming
Set data-accent="<color>" on the container or any ancestor (e.g. <body>) to theme the accent. Affects the soft variant’s track and the focus ring. Light/dark flips automatically via the .dark class on a parent — see Light & dark.
<body data-accent="iris"> <div class="unset x-segmented-control" data-variant="soft" role="radiogroup" aria-label="Tone"> <label><input type="radio" name="tone" value="quiet" checked /><span>Quiet</span></label> <label><input type="radio" name="tone" value="bold" /><span>Bold</span></label> </div></body>How the indicator works
Two layers, feature-detected with @supports (anchor-name: --x):
- Tier 1 — universal. Each
<label>::beforeis the indicator; defaultopacity: 0, raised to1on:has(:checked). 100ms ease-out transition gives a gentle crossfade between segments. No N math, no per-index rules. - Tier 2 — modern, slide. Inside the
@supportsblock the per-label::beforeis hidden and a single.x-segmented-control::afterelement slides between segments. The active label becomes the anchor (anchor-name: --x-seg-activeon:has(:checked)) and the indicator readsanchor(--x-seg-active left)/anchor-size(--x-seg-active width)to follow it. Works at any segment count without changing the stylesheet.
Author writes the same markup either way. Browsers without anchor positioning get the crossfade; browsers with it get the slide.
Accessibility
Native radios carry the entire APG pattern. CSS only paints. Author responsibilities, in order of importance:
- A group label is mandatory. Either wrap the control in
<fieldset>+<legend>(preferred — free for screen readers and form labels) or putrole="radiogroup"plusaria-labelledby/aria-labelon the container. - Shared
name=across every radio in the group. Differentnames break single-select and break the keyboard model. value=on every radio so form submission produces a meaningful entry.- Preselect a default. Without
checked, screen readers announce “0 of N selected” — a smell that reads as broken. Skip preselection only when the field is genuinely optional and the surrounding UI makes that clear. - Disabled semantics. Use
<fieldset disabled>for the whole group ordisabledon a single<input>; don’t togglearia-disabledmanually. Native arrow-key nav skips disabled inputs automatically. - Focus model. Native: Tab into the group, ←/→/↑/↓ to move + select, Tab out. CSS surfaces the focus ring on the visible label, not on the hidden input.
- Don’t rely on color alone. Active segment also bumps
font-weight: var(--font-weight-medium)andletter-spacing: var(--tab-active-letter-spacing). - Forced colors. A 1px border on the indicator and
Highlight/HighlightText/ButtonTextfallbacks keep it visible in Windows High Contrast. - Reduced motion.
prefers-reduced-motion: reduceremoves every transition.
APG pattern: WAI-ARIA Authoring Practices — Radio Group.
When not to use
- Multi-select — use a checkbox group; segmented controls are single-select by nature.
- 7+ options — switch to a
<select>or a listbox; segmented controls compete for space the more segments you add. - Tabs — switching a visible panel is the Tabs APG pattern (roving tabindex +
aria-controls), not radios.
Browser support
- Tier 1 (crossfade) — works in every browser meeting
elements-kit’s build targets (Chrome / Firefox 100+, Safari 16+). Relies on:has(), shipped in Safari 15.4, Chrome 105, Firefox 121. - Tier 2 (anchor slide) — auto-enables on browsers that ship CSS Anchor Positioning (Chrome / Edge 125+, Safari 26+, Firefox once shipped unflagged). No polyfill needed.
A browser that lacks both :has() and anchor positioning still gets functional, accessible radios — just without an indicator.