<segmented-elemental>
A row of radio buttons drawn as one track with a knob that slides between them โ the
N-state answer to <switch-elemental>. The segments stay
<input type="radio">, so the whole of the
APG Radio Group pattern is the
browser's. Light DOM, no shadow root, nothing moved or wrapped.
<segmented-elemental aria-label="Range">
<label><input type="radio" name="range" value="day" /> Day</label>
<label><input type="radio" name="range" value="week" checked /> Week</label>
<label><input type="radio" name="range" value="month" /> Month</label>
</segmented-elemental>
Usage
Write the radio group you would have written anyway โ one <label> per segment, each
wrapping its own <input type="radio">, all of them sharing a name โ and wrap it.
Labels must be direct children and the input must be inside its label; without either
there is nothing to coordinate, and nothing is enforced beyond that. Edit the sample and
the preview above it follows as you type:
<segmented-elemental aria-label="View">
<label><input type="radio" name="view" value="grid" checked /> Grid</label>
<label><input type="radio" name="view" value="list" /> List</label>
<label><input type="radio" name="view" value="map" /> Map</label>
</segmented-elemental>import 'book-of-elementals/segmented';
@use "book-of-elementals/segmented/style.scss"; // structure
@use "book-of-elementals/segmented/theme.scss"; // the look, optional
Or the single-element bundle โ no build step, no script to write:
<script src="https://unpkg.com/book-of-elementals/dist/elementals/segmented.min.js"></script>
<link
rel="stylesheet"
href="https://unpkg.com/book-of-elementals/dist/elementals/segmented.min.css"
/>
<link
rel="stylesheet"
href="https://unpkg.com/book-of-elementals/dist/elementals/segmented-theme.min.css"
/>
It registers itself on include and upgrades on connect. Nothing on window, nothing to
instantiate, no init call to forget.
What the element does, and what the browser does
Almost all of it is the browser's, because the segments are real radio buttons:
| Behaviour | Whose |
|---|---|
| Arrows move the selection, wrapping at both ends | the browser |
| Tab enters and leaves the group once | the browser |
| Focus lands on the checked segment | the browser |
name / value submit, required, reset, restore |
the browser |
| Announced as a radio, and as one of a group | the browser |
A <fieldset disabled> takes the whole group with it |
the browser |
| Where the knob is, and whether there is one | this element |
So there are no roles written here, no aria-checked, and no event of its own โ a radio
fires change, and change bubbles.
API
There are no attributes. The element reads the radios and writes the selection onto itself:
| What | Value |
|---|---|
--segmented-elemental-index |
Index of the checked segment. The knob's position |
--segmented-elemental-count |
How many segments there are. The knob's width |
data-index |
The same index, as an attribute. What the knob hangs off |
role="group" |
Only if you gave it aria-label / aria-labelledby, see below |
Properties
| Property | Type | Description |
|---|---|---|
inputs |
HTMLInputElement[] |
Read-only. The segments' radios, in document order. |
selectedIndex |
number | Read-only. Index of the checked one, or -1 for no selection. |
apply() |
โ | Re-read the selection and the count. Call it after adding or removing a segment. |
Events
None of its own. The radios are native, so use change โ it fires on the input and
bubbles, which means one listener on the group, or on the form, hears every segment:
const range = document.querySelector('segmented-elemental');
range.addEventListener('change', (e) => e.target.value); // 'day' | 'week' | 'month'
Setting .checked from script fires nothing โ that is the platform's rule for every form
control โ so move the knob with it:
range.inputs[2].checked = true;
range.apply();
Styling hooks
segmented-elemental[data-index="0"] {
} /* the host, once it knows the selection */
segmented-elemental > label:has(> input:checked) {
} /* what the theme keys off */
segmented-elemental > label:has(> input:disabled) {
} /* own, or a fieldset's */
segmented-elemental:not(:defined) {
} /* before upgrade */
Naming
Naming the group
A group of radios sharing a name is already a group to a screen reader, but the group
itself still needs a name. Two ways, in order of preference:
<!-- 1. Native: fieldset and legend, which also gives you `disabled` for free -->
<fieldset>
<legend>Range</legend>
<segmented-elemental> โฆ </segmented-elemental>
</fieldset>
<!-- 2. On the element, where a fieldset would be too much furniture -->
<segmented-elemental aria-label="Range"> โฆ </segmented-elemental>
aria-label on an element with no role is silently nothing, so the element adds
role="group" when it finds one of the two ARIA attributes and no role of your own.
That is the only ARIA it writes.
Naming a segment
The label's text is the segment's name, so a text segment needs nothing. An icon-only
segment has no text to be named by, and needs one of these โ an aria-label on the
input, since that is the control:
<segmented-elemental aria-label="Align">
<label>
<input type="radio" name="align" value="start" aria-label="Left" checked />
<svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true">
<path
d="M1 2h14M1 6h9M1 10h14M1 14h9"
stroke="currentColor"
stroke-width="1.5"
/>
</svg>
</label>
<label>
<input type="radio" name="align" value="center" aria-label="Center" />
<svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true">
<path
d="M1 2h14M4 6h8M1 10h14M4 14h8"
stroke="currentColor"
stroke-width="1.5"
/>
</svg>
</label>
<label>
<input type="radio" name="align" value="end" aria-label="Right" />
<svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true">
<path
d="M1 2h14M6 6h9M1 10h14M6 14h9"
stroke="currentColor"
stroke-width="1.5"
/>
</svg>
</label>
</segmented-elemental>aria-hidden on the SVG, because it is the name drawn rather than a second name. Draw
icons in currentColor and they take the segment's colour, selected or not.
In a form
Nothing to wire. The segments are the form controls, so the group submits under its
shared name, resets to whatever the markup checked, restores on a back-navigation,
honours required on any one of the radios, and goes down with a <fieldset disabled>:
<form>
<fieldset>
<legend>Billing period</legend>
<segmented-elemental>
<label><input type="radio" name="period" value="monthly" checked /> Monthly</label>
<label><input type="radio" name="period" value="yearly" /> Yearly</label>
</segmented-elemental>
</fieldset>
<button>Save</button>
</form>
new FormData(form).get('period'); // 'monthly' | 'yearly'
form.querySelector('input[name="period"]:checked').value; // the same, live
A group with nothing checked submits nothing at all, and draws no knob โ see below.
Degrading
The element is a knob and a count; everything under it works without it.
| Missing | What you get |
|---|---|
| The script never loads | A radio group with no knob. The selected segment still takes its colour, because that is label:has(> input:checked) in CSS |
| The theme is not imported | An unstyled but correctly laid out row, with a focus ring โ the ring is in style.scss, not the theme |
Nothing is checked |
No knob, rather than a knob on the first segment claiming a choice nobody made |
That last one is why the knob hangs off data-index rather than off the custom property:
CSS cannot ask whether a custom property was set, and an unset one inside calc() leaves
the knob at zero.
The one thing to watch is contrast: a selected label coloured to read against a filled
knob โ white on purple, say โ sits on the bare track in the row above, where there is no
knob to read against. Pick a --segmented-elemental-color-selected that is legible on
the track too, or carry the colour on the track instead of the knob.
The look
style.scss is structure only; theme.scss is the look and is optional โ a light-DOM
element cannot scope a look away from a page that did not ask for one. It is a track with
a sliding knob, mixed out of currentcolor, so it sits in whatever palette the page has:
| Property | Default | Description |
|---|---|---|
--segmented-elemental-gap |
3px |
Between the knob and the inside of the track |
--segmented-elemental-radius |
999px |
Track corners. A big number is a pill at any height |
--segmented-elemental-border-width |
1px |
Track border |
--segmented-elemental-border-color |
transparent |
Track border |
--segmented-elemental-track |
currentcolor at 10% |
Track fill, behind every segment |
--segmented-elemental-knob |
Canvas |
Knob fill |
--segmented-elemental-knob-radius |
the track's | Knob corners |
--segmented-elemental-color |
currentcolor |
Label colour, unselected |
--segmented-elemental-color-selected |
currentcolor |
Label colour, selected |
--segmented-elemental-padding-block |
0.375rem |
Segment padding โ this sets the height |
--segmented-elemental-padding-inline |
0.875rem |
Segment padding |
--segmented-elemental-duration |
250ms |
Knob slide and colour cross-fade |
--segmented-elemental-easing |
ease-in-out |
Knob slide and colour cross-fade |
That is the table above, live. Turn the knobs in the Options tab until it looks the way you want, then copy the rule out of the bottom of the panel:
<segmented-elemental aria-label="Turn me in the Options tab">
<label><input type="radio" name="knobs" value="1" checked /> One</label>
<label><input type="radio" name="knobs" value="2" /> Two</label>
<label><input type="radio" name="knobs" value="3" /> Three</label>
</segmented-elemental>Two properties are not in the panel, and deliberately: --segmented-elemental-index and
--segmented-elemental-count are the state the element writes, not knobs to turn โ set
one by hand and the knob leaves the segment the reader actually chose.
Colouring the selection
The label sits on top of the knob, so the two colours are chosen together. Which one carries the colour is the whole of the choice:
<segmented-elemental aria-label="Coloured knob" class="knob">
<label><input type="radio" name="a" value="1" checked /> One</label>
<label><input type="radio" name="a" value="2" /> Two</label>
</segmented-elemental>
<segmented-elemental aria-label="Coloured track" class="track">
<label><input type="radio" name="b" value="1" checked /> One</label>
<label><input type="radio" name="b" value="2" /> Two</label>
</segmented-elemental>
<style>
/* the knob carries it: a filled knob, and a label that contrasts with the fill */
.knob {
--segmented-elemental-knob: #7c5cff;
--segmented-elemental-color-selected: white;
}
/* the track carries it: a pale knob, and the label is the thing that changes colour */
.track {
--segmented-elemental-track: color-mix(in srgb, #7c5cff 18%, transparent);
--segmented-elemental-knob: Canvas;
--segmented-elemental-color-selected: #7c5cff;
}
</style>Square it off with the two radii, which want to stay concentric โ the knob's inner corner plus the gap is the track's:
segmented-elemental {
--segmented-elemental-radius: 0.5rem;
--segmented-elemental-knob-radius: 0.25rem;
}
prefers-reduced-motion: reduce switches the slide off. Under forced-colors the knob
is repainted Highlight and the label on it HighlightText, because that is the one
pair the mode guarantees contrasts. A disabled segment is the same segment at
opacity: 0.5 with a not-allowed cursor:
<segmented-elemental aria-label="Quality">
<label><input type="radio" name="q" value="low" checked /> Low</label>
<label><input type="radio" name="q" value="high" /> High</label>
<label><input type="radio" name="q" value="lossless" disabled /> Lossless</label>
</segmented-elemental>Note
The properties go on the <segmented-elemental> โ a class on it,
.card segmented-elemental, or the element itself. The theme sets its defaults on the
element, and a property set on an element always beats one inherited from an ancestor,
so .toolbar { --segmented-elemental-knob: โฆ } silently does nothing.
Note
--segmented-elemental-knob defaults to Canvas, the page's own background, because
that is what a knob lifted out of a filled track is. Re-point it on a card
(--segmented-elemental-knob: var(--card-background)), and on a page that themes in
custom properties without declaring color-scheme โ there Canvas stays white in
dark mode and the knob becomes a white slab.
Layout
<segmented-elemental> is an inline-grid of equal columns: every segment is one track
wide whatever its text, so the row does not reflow as the selection moves, and the knob is
exactly one track. Give it display: grid and a width of your own to stretch it across a
toolbar. Equal tracks are the trade: a segment whose label is much longer than the rest
sets the width for all of them.
Segmented control, tabs, or a switch?
| Wanted | Element |
|---|---|
| One choice out of two or a few, and it applies at once | this |
| One choice out of two, on or off | <switch-elemental> |
| One choice that swaps a panel of content below it | <tabs-elemental> |
| More options than fit on one row, or a list of them | <select>, or a plain radio list |
Tabs and this one look alike and promise different things: tabs own a panel and are navigation within a page, a segmented control is a form control that happens to be horizontal. If the thing it changes is a region of content with its own heading, it is tabs.