Tabs and segmented
tier 2 — classes
Two ways to offer a small set of choices. .tabs swaps a region of
content; .segmented changes a setting. If there is a panel
below it, it is tabs.
role="tablist", role="tab", aria-selected and
aria-controls, plus arrow-key movement between them. Without that a screen
reader hears a row of unrelated buttons. The segmented control needs none of it, because it
is built from radios.
Tabs
The indicator is a transparent border that gains a colour, so the label does not jump when it is selected. A long row scrolls sideways rather than wrapping — a wrapped second row pushes the content down and the underline stops reading as one strip.
The selected tab can be marked either way: aria-selected="true", which is what
a screen reader announces, or .tab--active for a tab strip that is genuinely
not a tablist. Prefer the attribute — with the class alone the styling and the announcement
can disagree.
The demo is live. data-tabs on the container hands the strip
to drSimpleUi.tabs, which is where the behaviour the CSS cannot express lives:
clicking, arrow keys, Home/End, showing the matching
aria-controls panel, and the roving tabindex that makes
Tab step over the whole tablist instead of through every tab in it. An app that
drives its tabs from C# leaves data-tabs off — otherwise two things set
aria-selected — and wires the arrow keys in the component.
Twelve orders are waiting on a first response.
Three are yours: ORD-4209, ORD-4244 and ORD-4250.
Everything in the queue, decided or not.
<div>
<div class="tabs" role="tablist" data-tabs aria-label="Order views">
<button class="tab" role="tab" aria-selected="true" id="t-open" aria-controls="p-open" type="button">
<i class="ri-inbox-line"></i> Open <span class="tab-count">12</span>
</button>
<button class="tab" role="tab" aria-selected="false" id="t-mine" aria-controls="p-mine" type="button" tabindex="-1">
Assigned to me <span class="tab-count">3</span>
</button>
<button class="tab" role="tab" aria-selected="false" id="t-all" aria-controls="p-all" type="button" tabindex="-1">
Everything
</button>
<button class="tab" role="tab" aria-selected="false" aria-disabled="true" disabled type="button" tabindex="-1">
Archive
</button>
</div>
<div class="tab-panel" role="tabpanel" id="p-open" aria-labelledby="t-open">
<p class="lede">Twelve orders are waiting on a first response.</p>
</div>
<div class="tab-panel" role="tabpanel" id="p-mine" aria-labelledby="t-mine" hidden>
<p class="lede">Three are yours: ORD-4209, ORD-4244 and ORD-4250.</p>
</div>
<div class="tab-panel" role="tabpanel" id="p-all" aria-labelledby="t-all" hidden>
<p class="lede">Everything in the queue, decided or not.</p>
</div>
</div>
Segmented control
Radios under the hood, so arrow keys move between the options and the chosen one is
announced as chosen — with no ARIA at all. Give every input the same name,
and give the group a name with a <fieldset> or
aria-labelledby, or a screen reader announces three loose radios.
Reach for it instead of a <select> when the options are few and short:
a select costs one click before the reader even learns what the choices are.
<div style="display:flex; flex-direction:column; gap:16px; align-items:flex-start">
<fieldset class="form-section" style="margin:0">
<legend>Time range</legend>
<div class="segmented">
<label class="segmented-option"><input type="radio" name="range" /> 24h</label>
<label class="segmented-option"><input type="radio" name="range" checked /> 7d</label>
<label class="segmented-option"><input type="radio" name="range" /> 30d</label>
</div>
</fieldset>
<div class="segmented">
<label class="segmented-option"><input type="radio" name="view" checked /><span><i class="ri-list-check"></i> List</span></label>
<label class="segmented-option"><input type="radio" name="view" /><span><i class="ri-layout-grid-line"></i> Grid</span></label>
<label class="segmented-option"><input type="radio" name="view" disabled /><span><i class="ri-calendar-line"></i> Calendar</span></label>
</div>
</div>
Tabs in a card
Live — switch between them. The tablist goes in
.card-head and the panels in the body, so the card head reads
as the card's own switch rather than as a row of buttons above it.
Only the selected tab is a tab stop; Tab moves past the whole list and the arrow
keys move within it. data-tabs maintains that roving tabindex, so
write it in the markup for the initial state and leave the rest alone.
| Item | Qty | Reserved |
|---|---|---|
| Desk lamp, matte | 2 | 2 |
| Cable tray, 600 mm | 1 | 1 |
| Monitor arm, dual | 2 | 0 |
- 09:15 Three of five lines reserved automatically.
- 09:18 Held for a decision by Alex Fischer.
<div class="card" style="max-width:520px">
<div class="card-head">
<div class="tabs" role="tablist" data-tabs aria-label="ORD-4209 detail">
<button class="tab" role="tab" aria-selected="true" id="c-lines" aria-controls="cp-lines" type="button">
Lines <span class="tab-count">5</span>
</button>
<button class="tab" role="tab" aria-selected="false" id="c-history" aria-controls="cp-history" type="button" tabindex="-1">
History
</button>
</div>
</div>
<div class="card-body">
<div class="tab-panel" role="tabpanel" id="cp-lines" aria-labelledby="c-lines">
<table class="table">
<thead>
<tr><th>Item</th><th>Qty</th><th class="col-num">Reserved</th></tr>
</thead>
<tbody>
<tr><td>Desk lamp, matte</td><td>2</td><td class="col-num">2</td></tr>
<tr><td>Cable tray, 600 mm</td><td>1</td><td class="col-num">1</td></tr>
<tr><td>Monitor arm, dual</td><td>2</td><td class="col-num">0</td></tr>
</tbody>
</table>
</div>
<div class="tab-panel" role="tabpanel" id="cp-history" aria-labelledby="c-history" hidden>
<ol class="timeline">
<li>
<span class="timeline-when">09:15</span>
<span class="timeline-what">Three of five lines reserved automatically.</span>
</li>
<li>
<span class="timeline-when">09:18</span>
<span class="timeline-what">Held for a decision by Alex Fischer.</span>
</li>
</ol>
</div>
</div>
</div>
Segmented in a toolbar
Two of them in one bar: a range inside .toolbar-filters, because it narrows what
is listed, and a layout switch at the far end, because it changes how the same list is drawn.
Which side a control sits on is what says whether it filters or presents.
A segmented control inside a toolbar still needs a group name. aria-label on the
.segmented is enough here; a <fieldset> would add a border and
a legend the bar has no room for.
<div class="toolbar">
<div class="toolbar-filters">
<input class="form-input toolbar-input" type="search" placeholder="Search orders…" />
<div class="segmented" role="group" aria-label="Time range">
<label class="segmented-option"><input type="radio" name="tb-range" /> 24h</label>
<label class="segmented-option"><input type="radio" name="tb-range" checked /> 7d</label>
<label class="segmented-option"><input type="radio" name="tb-range" /> 30d</label>
</div>
</div>
<div class="segmented" role="group" aria-label="Layout">
<label class="segmented-option">
<input type="radio" name="tb-view" checked /><span><i class="ri-list-check"></i> List</span>
</label>
<label class="segmented-option">
<input type="radio" name="tb-view" /><span><i class="ri-layout-grid-line"></i> Grid</span>
</label>
</div>
</div>