ui-009

Segmented Control

£10 or the whole Vault, £99

A segmented control for view and timeframe switching: the indicator stretches as it slides, with icons, disabled and loading states.

intermediate
6 more details
micro-interactionelasticclick-statestate-transitionkeyboard-navigationprogress-bar

This element reads better at your own screen size than in the frame below:

Updated

About this component

A segmented control at the depth a real product needs. The indicator is measured against the option it is moving to, and it stretches along its travel axis in proportion to the distance it is covering: a hop between neighbours barely deforms, a jump across the strip smears and then settles with an elastic overshoot. The active label travels with the pill rather than switching on the click, so a long jump lights each option it passes.

Read the full effect overview

Options can carry an icon as well as a label, be disabled (skipped by the keyboard and visibly inert) or be put into a busy state while something async resolves, where the indicator holds position and a progress sweep runs inside it. When there are more options than fit, the strip scrolls horizontally with fade edges and keeps the selected option in view. It is a radiogroup with a roving tabindex, arrow keys, Home and End, and a small public API with events so the rest of your UI can drive it. Light and dark are pure CSS: the script never branches on a theme name.

Segmented Control - UI component preview

What's included

11 items
  • Indicator stretch is proportional to travel: a two-option hop and a four-option jump look different
  • Elastic settle on arrival, with the pill's leading edge anchored so the body smears behind it
  • The active-label colour travels with the pill, lighting each option it passes on a long jump
  • Options take an icon as well as a label, and the icon pops on selection
  • Disabled options are skipped by the keyboard and visibly inert
  • Busy state per option: the indicator holds and a progress sweep runs until the caller resolves it
  • Overflow strip scrolls horizontally with fade edges and keeps the selection in view
  • Roving tabindex, Left/Right/Up/Down, Home, End and Enter/Space, on a real radiogroup
  • Public API (select, setBusy, setEnabled, options) plus segmented:change / busy / toggle events
  • Works without JavaScript: the checked option carries its own underline
  • Re-measures on resize, after web fonts load and after a theme switch

Perfect for

4 use cases
  • Monthly / Annual / Lifetime billing toggles
  • Dashboard section and view switchers with icons
  • Timeframe pickers with more ranges than fit on a phone
  • Settings and filter groups where one option loads asynchronously

How it works

4 sections

Distance drives the deformation

Every change measures the target option against the track and compares the new x with the one the indicator is holding. That distance, as a fraction of the strip, sets three things at once: how far the pill stretches along its travel axis, how long the tween runs, and which edge the stretch is anchored to. A neighbouring hop is almost rigid; a jump across the strip smears and then settles on an elastic overshoot.

The label follows the pill, not the click

The active-label class is reassigned on every frame of the tween, to whichever option the indicator's centre is nearest. On a long jump the pill lights each option it crosses, and the label under it never flips early. aria-checked, by contrast, moves on the click, so assistive technology hears the new state immediately.

Busy without losing the position

setBusy holds the indicator where it is, marks the option aria-busy and loops a gradient sweep inside the pill until the caller resolves it. Selecting a different option cancels the busy state, so a slow request can never leave the control lying about what is selected.

Overflow that keeps the selection in view

The track is max-content inside an auto-scrolling viewport, so extra options extend the strip rather than squashing it. Selecting one scrolls it into view with a tween on scrollLeft, and two gradient edges fade in and out from the scroll position. Positions are measured against the track rather than the viewport, so scrolling never shifts the measurement.

Difficulty Intermediate
Includes HTML + JS + CSS source, documentation, AI setup prompt, lifetime updates

Lighthouse, as measured

Google Lighthouse on this UI element's demo, from the latest scan. A measurement of the demo as shipped, not a promise for your page.

Accessibility
100
Best practices
100

No performance score, on purpose. That figure depends on how you deploy: your server's compression and caching, your CDN, the connection and the device doing the test, none of which the code controls. The same page can score very differently on two consecutive runs, so measure it where it will live.

Paid UI element

Purchase to unlock the code.

Buying Segmented Control opens the HTML, CSS and JavaScript source, the full documentation, an AI setup prompt for your editor, and every update we ship to it. Standard license: unlimited personal and commercial projects. Every paid product is tested for desktop and mobile layouts, reduced-motion handling, animation cleanup and keyboard controls wherever there is something to operate.

£10 Standard license, unlimited projects

Browse UI elements

Or the whole library: the Vault, £99, one payment.

Documentation

Quick Start

1. Add to your HTML <head>:

Code snippet omitted: it ships with the download.

2. Add before the closing </body> tag:

Code snippet omitted: it ships with the download.

3. Add the component markup:

Copy the block marked <!-- Component starts here --> in index.html. Trimmed to its bones it is:

Code snippet omitted: it ships with the download.

Every element is found by data- attribute, so class names are yours to rename. data-value on the root picks the option selected on load; leave it off and the option with aria-checked="true" wins. Any number of switches can live on one page — each initialises independently.

Mark an option disabled with disabled aria-disabled="true". Drop the <svg> for a label-only option; drop the two seg__edge spans if the strip can never overflow.

Using It With Your Own Design

What the component needs from your markup: the data- attributes above, real <button> elements for the options, and the indicator as the first child of the track. Nothing else about the surrounding page matters.

What is only the demo's styling: everything below the "Demo furniture" comment in assets/style.css (.stage, .stage__inner, .stage-row and its label and hint) plus the toolbar strip, the "Simulate save" button and the theme toggle in index.html, and the Google Font link. The three labelled rows are there so a single control reads as a setting rather than as a lone bar on a blank page; none of it is part of what you bought. Delete all of it; the switch is display: block and sits wherever you put it.

CSS the component depends on:

  • The indicator keeps left: 0. Its whole offset is the measured x. A CSS left on top of it doubles the offset.
  • No CSS transform on .seg__indicator or .seg__icon — GSAP owns those, and a CSS transform underneath one is read as a pixel offset it never returns from.
  • .seg__track must stay position: relative and width: max-content (the indicator is positioned against it, and options must be free to overflow rather than squash).
  • .seg__scroller carries the bar's background, border and radius, and hides its own scrollbar.
  • No data-lenis-prevent on the scroller. It scrolls horizontally only, and that attribute would silently take Lenis off the page's vertical wheel as well.

What's Included

  • index.html: the demo — three switches as labelled settings rows, one of them with icons
  • assets/style.css: component CSS, both themes, and the demo furniture (clearly marked)
  • assets/script.js: readable GSAP core source with the keyboard model, public API and cleanup
  • assets/script.min.js: minified production script
  • thumbnail.html: build-only capture state

Themes

Two themes, one design: light (the default) and dark. Set the attribute on the component's owning root in a product page, or on <body> in the demo:

Code snippet omitted: it ships with the download.

Same markup, same script. The demo's toolbar toggle and the ?variant=dark URL parameter only set that attribute; neither is part of the component.

Each body[data-variant="…"] block in assets/style.css holds two short lists. The first is the ground a buyer re-values for their own brand:

--ground, --ground-2, --raised, --hover, --ink, --ink-2, --ink-3, --line, --line-strong, --accent, --accent-ink, --accent-wash, --shadow, --font, --radius, --radius-sm, --radius-xs

The second is the component, written in terms of the first, so in most cases you change nothing here at all: --bar-bg, --bar-border, --bar-radius, --bar-pad, --bar-shadow, --edge-bg, --ind-bg, --ind-top, --ind-height, --ind-radius, --ind-shadow, --ind-progress, --btn-fg, --btn-active-fg, --btn-hover-bg, --btn-off-fg, --btn-weight, --focus-ring.

Because the indicator's own geometry is in that list, a different look never needs a selector override: set --ind-top: calc(100% - 3px) and --ind-height: 3px and the pill becomes an underline while the script still tweens the same x, width and scaleX. The JavaScript never reads the theme name; where it needs a theme-dependent value it reads a custom property at use time.

A theme change can alter fonts and padding, so dispatch a resize (or call refresh()) after switching — the demo does this in its toggle.

How It Works

Distance drives the deformation. Each change compares the target x with the one the indicator is holding. That distance, as a fraction of the strip, sets how far the pill stretches, how long the tween runs, and which edge the stretch is anchored to (the leading edge, so the body smears behind it). A neighbouring hop is almost rigid; a jump across four options smears, then settles on elastic.out(1, 0.75).

The label follows the pill, not the click. The is-on class is reassigned every frame to whichever option the indicator's centre is nearest, so a long jump lights each option it crosses. aria-checked moves on the click instead, so assistive technology hears the new state immediately.

Measured against the track, not the viewport. Option rects are read relative to the track, which shifts with the scroll exactly as they do — so scrolling never corrupts the measurement. Geometry is re-read on resize, on document.fonts.ready, and whenever you call refresh().

Busy without losing the position. setBusy holds the indicator where it is, marks the option aria-busy and loops a gradient sweep inside the pill until you resolve it. Selecting a different option cancels it, so a slow request can never leave the control lying about what is selected.

Public API

The instances are exposed for wiring; window.segmentedSwitch is the first one on the page, window.segmentedSwitches is all of them in document order.

Code snippet omitted: it ships with the download.

Events

The root dispatches three bubbling CustomEvents:

Event detail
segmented:change value, label, index, previousValue, previousIndex
segmented:busy value, index, busy (true on start, false on resolve)
segmented:toggle value, index, enabled

Code snippet omitted: it ships with the download.

Accessibility

  • The track is a role="radiogroup" and each option a role="radio" with aria-checked. Label the group with aria-label or aria-labelledby.
  • Roving tabindex: the group is one stop in the tab order. Left/Right and Up/Down move to the previous or next enabled option and select it (the standard radiogroup model), Home and End jump to the first and last enabled option, Enter and Space select the focused one.
  • Disabled options are skipped by the arrow keys, are not focusable, and cannot be clicked.
  • Selection and the start of a busy state are announced through a polite role="status" region; a busy option is also marked aria-busy="true".
  • Focus is visible on every option through :focus-visible, inset so the ring is never clipped by the bar.
  • Coarse pointers get 44px-high targets, momentum scrolling on the strip, and no hover-only state.
  • Under prefers-reduced-motion: reduce the indicator jumps rather than springs, the strip scrolls instantly, the icon pop is off, and the busy sweep is a static bar.
  • With JavaScript off there is no indicator at all: the checked option carries its own underline, so the control still reads correctly.

Customisation

  • Colours, radii and the indicator geometry live in assets/style.css as custom properties per theme.
  • STRETCH_MAX at the top of assets/script.js is how far the pill may stretch on a full-width jump (0.42 = 42% of its own width). SCROLL_PAD is the gap kept between the selected option and the edge of the strip.
  • The settle is the elastic.out(1, 0.75) in applyIndicator; the duration ramps with distance in the same function.

Requirements

  • GSAP 3.12+ (the demo uses 3.15.0; core only)
  • No build step, no framework

Worked examples, the events and programmatic API, and the class reference ship with the download, alongside the full source.