← Back to gallery
CSS

Segmented Control Radio Tab Group

Segmented control built on real <input type="radio"> with an accent pill that slides under the checked option via translateX driven by a CSS custom property. Three layouts: horizontal day/week/month, vertical filters, and a compact two-segment pill.

segmented-controlradio-grouptranslateXpill-slidecss-custom-propertiesaria-checkedkeyboard-a11y

Radio-group · sliding pill indicator

Segmented Control Radio Tab Group

Three production-realistic segmented controls — a 2-option layout toggle, a 3-tier plan picker, and a 4-option analytics range. The sliding pill rides on top of a real radio group so keyboard, screen reader, and touch users all get the single-choice semantics, while a CSS transform keeps the indicator transition composited.

Three-option horizontal · X-axis pill

Horizontal

Three-option segmented control on the inline axis. The pill indicator slides horizontally between equal-width slots. This is the canonical toolbar / radio-group pattern.

  • inline axis
  • translateX
  • equal slots

Three-option vertical · Y-axis pill

Vertical

Same three-option control rotated to the block axis. The pill slides vertically between slots — a sidebar / settings-list segmented pattern. Same radio-group semantics, only the axis changes.

  • block axis
  • translateY
  • sidebar

Three-option uneven · variable pill width

Asymmetric

Three-option control with uneven slot widths — `[ A ][ B ][ C ]` — where the middle option is twice as wide as the side ones. Both the slot dimensions AND the pill width adapt per slot, so the indicator changes shape as it slides.

  • uneven slots
  • variable width
  • shape morph

Segmented inspector

Horizontal

click segment
Plan
  • inline axis
  • translateX
  • equal slots

Three-option segmented control on the inline axis. The pill indicator slides horizontally between equal-width slots. This is the canonical toolbar / radio-group pattern.

Helped you ship something? 🐟 Send my cat a churu

/* Inline axis: pill translates X between equal-width slots. */
.segmented {
  position: relative;
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  padding: 4px;
  background: rgba(15, 23, 42, 0.75);
  border: 1px solid rgba(248, 250, 252, 0.12);
  border-radius: 999px;
}

.segmented input {
  position: absolute;
  opacity: 0;
  width: 1px;
  height: 1px;
  overflow: hidden;
}

.segmented label {
  position: relative;
  z-index: 1;
  padding: 8px 14px;
  border-radius: 999px;
  text-align: center;
  cursor: pointer;
  color: rgba(226, 232, 240, 0.55);   /* inactive — dim */
  transition: color 0.26s ease;
}
.segmented input:checked + label {
  color: #f0fdfa;                     /* active — brightens */
}

.segmented::before {
  content: '';
  position: absolute;
  border-radius: 999px;
  background: color-mix(in srgb, #7dd3fc 26%, #0f172a);
  box-shadow:
    0 0 14px color-mix(in srgb, #7dd3fc 30%, transparent),
    inset 0 0 0 1px color-mix(in srgb, #7dd3fc 70%, transparent);
}

/* Track active option index via :has(). */
.segmented:has(input:nth-of-type(2):checked) { --active-index: 1; }
.segmented:has(input:nth-of-type(3):checked) { --active-index: 2; }

.segmented::before {
  inset: 4px;
  width: calc(100% / 3 - 8px);
  /* translateX is composited — no layout cost on the slide. */
  transform: translateX(calc(100% * var(--active-index, 0)));
  transition: transform 0.26s cubic-bezier(0.45, 1.4, 0.5, 1);
}

@media (prefers-reduced-motion: reduce) {
  .segmented::before, .segmented label { transition: none; }
}

How to make this

A CSS segmented control uses real radio inputs for single-choice semantics, then moves a decorative pill indicator across equal slots with transform.

html
1<fieldset class="segmented-control">  <legend>Billing period</legend>3  <input id="seg-month" name="billing" type="radio" checked />  <label for="seg-month">Month</label>  <input id="seg-year" name="billing" type="radio" />  <label for="seg-year">Year</label>  <input id="seg-team" name="billing" type="radio" />  <label for="seg-team">Team</label></fieldset> <style>.segmented-control {  --active-index: 0;  position: relative;  display: grid;  grid-template-columns: repeat(3, 1fr);  gap: 0;  width: min(100%, 20rem);  margin: 0;  padding: .25rem;  border: 1px solid rgba(148, 163, 184, .22);  border-radius: 999px;  background: rgba(15, 23, 42, .78);}.segmented-control legend {  position: absolute;  inline-size: 1px;  block-size: 1px;  overflow: hidden;  clip: rect(0 0 0 0);}.segmented-control input {33  position: absolute;  opacity: 0;}.segmented-control label {  position: relative;  z-index: 1;  padding: .55rem .75rem;  border-radius: 999px;  color: rgba(226, 232, 240, .62);  text-align: center;  cursor: pointer;  transition: color .22s ease;}.segmented-control::before {47  content: "";  position: absolute;  inset-block: .25rem;  inset-inline-start: .25rem;  width: calc((100% - .5rem) / 3);  border-radius: 999px;  background: rgba(125, 211, 252, .24);  box-shadow: inset 0 0 0 1px rgba(125, 211, 252, .55);  transform: translateX(calc(100% * var(--active-index)));  transition: transform .28s cubic-bezier(.2,.8,.2,1);57}.segmented-control input:checked + label { color: #f0fdfa; }.segmented-control input:focus-visible + label {  outline: 2px solid #7dd3fc;  outline-offset: 4px;}.segmented-control:has(#seg-year:checked) { --active-index: 1; }.segmented-control:has(#seg-team:checked) { --active-index: 2; }65@media (prefers-reduced-motion: reduce) {  .segmented-control::before,  .segmented-control label { transition: none; }}</style>

Annotated snippet

  1. Line 1Use fieldset and legend for the group, even when the legend is visually hidden. The pattern is a single-choice form control, not only a row of styled buttons.
  2. Line 3Real radio inputs provide arrow-key behavior, checked state, forms integration, and screen reader semantics. The visual pill should not replace the input state.
  3. Line 33The inputs are visually hidden but still present. Avoid display: none because that removes them from keyboard and accessibility behavior.
  4. Line 47The indicator is a pseudo-element behind the labels. It is decorative, so the selected state still comes from the checked radio rather than from the pill position.
  5. Line 57The pill moves with transform by one slot width per active index. Animating left or margin would touch positioned layout values and is easier to misalign.

    Same visible slide on both. The left version touches a positioned-layout value every frame — fine here, but any future width or padding change on the track will trigger layout for everything inside. transform moves the pill on the compositor without disturbing the slot math.

    PitfallIs the sliding pill animation expensive?

    A single transformed pseudo-element is cheap. Avoid animating grid columns, width, or left across a large toolbar unless the pill intentionally changes size, and keep label text from reflowing.

  6. Line 65:has() maps the checked radio back to a container variable. If you need older-browser support, set the same variable with a class or data-active attribute from JavaScript.
    PitfallHow do I support browsers without :has()?

    Keep the same radio markup and set a fallback class or data-active attribute on the fieldset when selection changes. That attribute can update --active-index the same way :has() does.

Other pitfalls

Should segmented controls use radio buttons?
Yes when exactly one option is selected. Radios give the right single-choice semantics, arrow-key interaction, and form behavior. Use tabs only when the control switches tab panels, not when it selects a value.
Why is my segmented control indicator misaligned?
The pill width and translate stride must use the same slot math. Account for container padding, and prefer transform: translateX(100% * index) when the pill width equals one slot.
What should prefers-reduced-motion do for segmented controls?
Remove the sliding transition but keep the instant selected state, label color, and focus outline. The chosen option should remain obvious without motion.

Notes

Overview

Segmented control built on real <input type="radio"> with a pill indicator that slides via translateX driven by a --active-index CSS custom property updated on change. Native radios drive the :checked state; the pill is purely decorative.

When to use it

Reach for segmented controls for two-to-four mutually exclusive options where all options should be visible at once — time-range pickers, view-mode switchers, tab replacements where the content below stays in place. Skip them for more than four options (use tabs) and for one-shot choices where the state does not persist visually.

How it works

Render N <input type="radio"> elements sharing the same name attribute, each with a matching <label>. Hide the inputs visually with opacity: 0 + absolute positioning, then style the labels as the visible segments. On each input change event, write --active-index: {N} on the wrapper element. A pill indicator (a single absolutely-positioned element under the labels) uses transform: translateX(calc(var(--active-index) * 100%)) with a transition to slide between positions. The pill is purely decorative; the radio inputs drive everything else.

Production gotchas

Mixing label widths (e.g. “Day” vs “Month”) breaks the 100% multiplier — either pad labels to equal width with flex: 1 or measure each label and write explicit --pill-x + --pill-w values on change. The pill must sit underneath the label text via z-index so click targets remain on the labels. Make sure labels are cursor: pointer and have enough padding to be comfortable touch targets — segmented controls crammed under 44px tall fail mobile usability.

Accessibility

Native radio inputs give arrow-key navigation and aria-checked announcement for free. Wrap them in a <fieldset> + <legend> describing the choice (the legend can be visually hidden with .sr-only styles). Under prefers-reduced-motion: reduce drop the pill transition so it snaps to the active segment. Verify focus ring visibility on the hidden input transfers visually to the label via :focus-visible on the input + sibling selector to the label.

References

Implementation depth

The reliable version starts with real radio or tab semantics, then lets the sliding pill become a visual confirmation of state. CSS custom properties should move the indicator; the checked control should still be the source of truth.

Avoid building this as buttons with only click handlers. Keyboard users expect arrow-key movement inside a radio group or tablist, visible focus on the active option, and a state that is announced before the animated pill catches up.