Components Controls & Input SegmentedControl

SegmentedControl

Joined picker for mutually exclusive options.

Live demo

01

View mode switcher

  • A modest case for monochrome UI design
  • Owning your design tokens engineering
  • Why we picked SvelteKit over Next engineering
  • Listening better in user interviews research

Time range (size="sm")

Showing data for: 1W

Custom palette

Theme: auto

Content width (equalWidth=false)

Plan: pro

Icons-only Β· narrow toolbar

Glyph-only segments for compact toolbars. The label is empty, so each pill collapses to just the icon.

Alignment: left

Content-fit with mixed widths

equalWidth={false} lets each segment hug its label, so β€œXS” stays tight while β€œExpansive ultra-wide” gets the room it needs.

Density: comfortable

Implementation

02
SegmentedControl.svelte
<script lang="ts">
  import SegmentedControl from '$lib/components/SegmentedControl.svelte';
  let view = $state('list');
  const options = [
    { value: 'list', label: 'List' },
    { value: 'grid', label: 'Grid' },
    { value: 'cards', label: 'Cards' }
  ];
</script>
​
<SegmentedControl {options} bind:value={view} ariaLabel="View mode" />

Each segment is a hidden native radio input wrapped by a label, so a single arrow-key tab handles selection β€” no JS keyboard plumbing required. The visual "pill" you see is a single transform: translateX() shifted to match the active index, which means the slide animation stays smooth no matter how many segments you add.

Logic explainer

03

What Does It Do? (Plain English)

A row of mutually-exclusive options visually joined into one control. iOS-style picker for switching between modes (List / Grid / Cards), time ranges (1D / 1W / 1M), or simple tab bars. Single-select only β€” the joined affordance reads as "pick one of these". A sliding indicator animates from the previous selection to the new one, so the eye tracks the change rather than re-scanning the row to find what's active now.

Think of it as a row of station-preset buttons on a car radio β€” exactly one is pressed in at any time, and pressing a new one makes the previous one pop out as this one goes down.

How It Works (Pseudo-Code)

state:
  value         = bindable string (the selected value)
  options       = [{ value, label, icon? }]

derive activeIndex:
  i = options.findIndex(o => o.value === value)
  return Math.max(0, i)        // clamp -1 β†’ 0 so the indicator
                                 // doesn't fly off-screen pre-mount

events:
  on radio change (newValue):
    value = newValue
    fire onChange(newValue)

render:
  container with --active-index and --option-count CSS vars
  ::before pseudo-element = the sliding indicator
  for each option:
    <label> wrapping a hidden radio <input> + label text

The sliding indicator is a single CSS pseudo-element (::before on the container) that translates by activeIndex Γ— 100% of segment-width. One animated element, regardless of how many segments β€” which is why the slide stays smooth no matter how many options you give it.

The Core Concept: One Indicator, Many Segments

The naΓ―ve approach is to colour the active segment's background. That works visually but produces a jarring effect when transitioning: as one segment turns dark, the previous one turns light, and the eye sees two simultaneous changes rather than one continuous motion.

This component flips the model. The "active" colour is owned by a single sliding pseudo-element, not by the segments themselves:

.segmented::before {
  content: '';
  position: absolute;
  width: calc((100% - 0.5rem) / var(--option-count));
  background: var(--active-bg);
  transform: translateX(calc(var(--active-index) * 100%));
  transition: transform 0.18s ease;
}

--option-count and --active-index are written by Svelte as inline custom properties on the container. The pseudo-element computes its width as 100% / option-count and translates by active-index Γ— 100%-of-its-own-width. When --active-index changes, the translate updates, and CSS interpolates the transform over 180 ms.

The segments themselves are transparent over the indicator (isolation: isolate + z-index ordering on the container). The active label gets a colour change to --active-text for contrast against the indicator background.

Why transform and not left? Transforms compose on the GPU and never trigger layout. Animating left would force the browser to re-layout the row on every frame.

Native Radios as Click Targets

Each segment wraps a native <input type="radio"> that's positioned absolutely to fill the entire segment, with opacity: 0:

.segment input {
  position: absolute;
  inset: 0;
  opacity: 0;
}

This single move buys three things:

  1. Clickability. The whole segment is a click target β€” clicking the icon, the label, or empty padding all hit the radio.
  2. Keyboard nav for free. Browsers handle Arrow ←/β†’/↑/↓ and Home/End on radio groups natively. No custom keydown handler needed.
  3. Screen reader semantics. AT announces "View mode, list, radio, 1 of 3, selected" β€” exactly the right wording. We use role="radiogroup" on the container with an aria-label to name the group.

The name prop seeds the radio group: each radio shares the same name, which is how the browser enforces single-select. If two SegmentedControls render on the same page without distinct name props, they'd interfere β€” so the default is an auto-generated random suffix.

State Flow Diagram

              β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
              β”‚   value = 'list'     β”‚
              β”‚   activeIndex = 0    β”‚
              β”‚   indicator at 0%    β”‚
              β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                          β”‚
                  user clicks 'grid'
                          β”‚
                          β–Ό
              β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
              β”‚   value = 'grid'     β”‚
              β”‚   activeIndex = 1    β”‚
              β”‚   indicator slides   β”‚ ← CSS transition: 180ms
              β”‚   to 100%            β”‚   (or instant if reduced-motion)
              β”‚   onChange('grid')   β”‚
              β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

  Keyboard:
    Tab       : focus the radiogroup
    Arrow R/D : focus & select next radio
    Arrow L/U : focus & select previous radio
    Home      : focus & select first
    End       : focus & select last
    Space     : (already selected on focus β€” no-op)

Props Reference

Prop Type Default Description
options { value: string; label: string; icon?: string }[] required Segments to render.
value string required (bindable) Selected value. Use bind:value for two-way sync.
size 'sm' | 'md' 'md' Control height (28 px vs 32 px).
equalWidth boolean true All segments share the same width. Set to false for content-fit segments.
activeBg string '#ffffff' Active indicator background colour.
activeText string '#1f2937' Active label text colour.
ariaLabel string 'Segmented control' radiogroup label.
name string auto Radio group name. Auto-generated; only set if you have multiple controls on one page and want stable IDs.
onChange (value: string) => void β€” Fires after selection changes.
class string '' Extra classes on the wrapper.

Edge Cases

Situation Behaviour
value doesn't match any option (e.g. uninitialised state) findIndex returns -1; clamped to 0 so the indicator parks on the first segment. The first segment is not selected β€” value stays unchanged.
equalWidth={false} and segments have different label lengths The component swaps from the shared sliding indicator to an active-segment fill, so content-fit controls stay aligned even when labels have mixed widths.
User has prefers-reduced-motion: reduce The 180 ms slide is removed; the indicator teleports to the new position.
Six or more segments They get cramped on mobile and the joined affordance stops reading as "pick one". Switch to Tabs or a <select> dropdown.
Two SegmentedControls on the same page without name overrides Each gets an auto-generated name; the radio groups don't interfere.
Touch device with no hover Tap selects exactly like click β€” no hover-only state.
options array is empty The radiogroup renders with no segments; --option-count: 0 makes the indicator width Infinity divided. The browser handles this gracefully (no segments to display). Don't pass an empty array.

Dependencies

  • Svelte 5.x β€” $bindable, $derived, $props. One handler, one derived index.
  • Zero external dependencies. Native <input type="radio">, native <label>, scoped CSS.

File Structure

src/lib/components/SegmentedControl.svelte    # implementation
src/lib/components/SegmentedControl.md        # this file (rendered inside ComponentPageShell)
src/lib/components/SegmentedControl.test.ts   # vitest unit tests
src/routes/segmentedcontrol/+page.svelte      # demo page

API

04
PropTypeDefaultDescription
optionsArray<{ value, label, icon? }>β€”Required. List of segments.
valuestring''Bindable currently selected value.
size'sm' | 'md''md'Compact or default segment height.
equalWidthbooleantrueForce every segment to share the same width, or fit to content.
activeBg / activeTextstringβ€”Custom palette for the active pill.
ariaLabelstring'Segmented control'Group label for assistive tech.
namestringautoForm name for the underlying radio group. Auto-generated if omitted.
onChange(value) => voidβ€”Fires when the selection changes.