You have seen this control a hundred times. A row of options packed into one pill, with a white highlight that glides from the old choice to the new one when you tap. iOS calls it a segmented control. GitHub, Linear and Figma all use it for their list/grid toggles. Today I rebuilt it from scratch in plain HTML, CSS and JavaScript, and the interesting parts are not where I expected.
Here is the finished thing, all three tabs (a live demo, a step-by-step explainer, and a build guide): https://dev48v.infy.uk/design/day25-segmented-control.html
It is radio buttons in a nicer coat
The first decision is semantic, and it is easy to get wrong. The pill looks like tabs, so the tempting move is to reach for role="tablist". Do not. A tab makes a promise to a screen reader: "activate me and I reveal an associated panel." A segmented control does not reveal a panel. It stores a value, exactly like a group of radio buttons. So the honest markup is a radio group:
<div class="seg" role="radiogroup" aria-label="View">
<div class="seg-thumb"></div>
<button role="radio" aria-checked="true" tabindex="0">List</button>
<button role="radio" aria-checked="false" tabindex="-1">Grid</button>
<button role="radio" aria-checked="false" tabindex="-1">Board</button>
</div>
Use a tablist only if each segment genuinely swaps a chunk of content. If it sets a value that sticks, it is a radio group. That single choice makes the whole thing announce correctly as "radio group, option 2 of 4, checked."
The sliding thumb: measure, do not assume
The signature move is that gliding highlight. The naive way is to assume every segment is one over the count of the width, and place the thumb at index times slot width. It works right up until your labels are different lengths, and then "Justify" and "Day" wreck the alignment.
The fix is to stop assuming and start measuring. Render one absolutely positioned div inside the track, and on every selection read the chosen segment's real geometry:
function moveThumb(){
const seg = opts[cur];
thumb.style.width = seg.offsetWidth + "px";
thumb.style.height = seg.offsetHeight + "px";
thumb.style.transform =
`translate(${seg.offsetLeft}px, ${seg.offsetTop}px)`;
}
Because the thumb starts at 0,0 inside the same track, those offsets are already in its coordinate space. No getBoundingClientRect maths. Equal-width layouts (flex: 1 on each option) and natural-width layouts both work, because the thumb just copies whatever the browser laid out. The lesson generalises far past this widget: prefer measuring the DOM over recomputing what the browser already knows.
One perf note. Animate transform, never left. Animating left re-runs layout every frame and stutters on slow devices. transform: translate(...) rides the compositor and stays smooth. Width still needs a transition when segments differ, but that is one property on one element, so the cost is tiny.
Keyboard: one tab stop, arrows that commit
A segmented control has several focusable buttons, but Tab should treat the whole thing as one stop. That is roving tabindex: only the checked option gets tabindex="0", every other one is -1. Tab lands on the group once and then leaves; the arrows move inside.
There is a subtle radio-group rule here that trips people up. In a radio group the arrow keys move focus and change the selection at the same time. There is no "focus this one but leave the old one checked" state, the way a menu has. So both mouse and keyboard funnel through one function:
function select(i, focus){
const n = opts.length;
i = ((i % n) + n) % n; // wrap both ends
opts.forEach((o, j) => {
o.setAttribute("aria-checked", j === i ? "true" : "false");
o.tabIndex = j === i ? 0 : -1; // rove the tabindex
});
cur = i; moveThumb();
if (focus) opts[i].focus();
}
Right and Down go next, Left and Up go previous, both wrapping. Home and End jump to the ends. The ((i % n) + n) % n dance is there to handle the negative index you get when you press Left on the first item.
The three details that separate real from demo
Resize: segment widths change, so re-run moveThumb, but guard for a hidden container where offsets read zero. First paint: the thumb starts at 0,0, so disable its transition, position it once, then restore, or it visibly slides in from the corner on load. Reduced motion: some people get queasy from sliding UI, so one prefers-reduced-motion media query drops the transition and lets the pill jump instead.
That is the whole thing in about seventy lines of vanilla JS. No framework, no dependencies, and it behaves exactly as its ARIA role promises. Full working demo and the step-by-step build: https://dev48v.infy.uk/design/day25-segmented-control.html
Top comments (0)