Skip to content

Segmented Control

An iOS-style pill switch whose highlight slides between options.

Freeminimalglass
Category
Components
Added
2026-08-23
Deps
1
Updated
2026-09-01
Preview

Props

Requires framer-motion
"use client";

import { useRef, useState } from "react";
import { motion } from "framer-motion";

export function SegmentedControl({
  options = ["Day", "Week", "Month"],
  defaultIndex = 0,
  onChange,
}: {
  options?: string[];
  defaultIndex?: number;
  onChange?: (index: number) => void;
}) {
  const [active, setActive] = useState(defaultIndex);
  const buttonRefs = useRef<(HTMLButtonElement | null)[]>([]);

  function focusAndActivate(i: number) {
    const next = (i + options.length) % options.length;
    setActive(next);
    onChange?.(next);
    buttonRefs.current[next]?.focus();
  }

  function handleKeyDown(e: React.KeyboardEvent, i: number) {
    switch (e.key) {
      case "ArrowRight":
        e.preventDefault();
        focusAndActivate(i + 1);
        break;
      case "ArrowLeft":
        e.preventDefault();
        focusAndActivate(i - 1);
        break;
      case "Home":
        e.preventDefault();
        focusAndActivate(0);
        break;
      case "End":
        e.preventDefault();
        focusAndActivate(options.length - 1);
        break;
      default:
        break;
    }
  }

  return (
    <div role="tablist" className="inline-flex gap-0.5 rounded-full border border-neutral-200 bg-neutral-50 p-1">
      {options.map((option, i) => (
        <button
          key={option}
          ref={(el) => {
            buttonRefs.current[i] = el;
          }}
          role="tab"
          aria-selected={active === i}
          tabIndex={active === i ? 0 : -1}
          onClick={() => {
            setActive(i);
            onChange?.(i);
          }}
          onKeyDown={(e) => handleKeyDown(e, i)}
          className="relative rounded-full px-4 py-1.5 text-sm font-medium"
        >
          {active === i && (
            <motion.div
              layoutId="segmented-highlight"
              transition={{ type: "spring", stiffness: 400, damping: 32 }}
              className="absolute inset-0 rounded-full bg-red-500"
            />
          )}
          <span className={`relative z-10 ${active === i ? "text-white" : "text-neutral-500"}`}>
            {option}
          </span>
        </button>
      ))}
    </div>
  );
}
Notes

About this component

An iOS-style segmented control needs its selection indicator to slide between options, not cross-fade; those read as two entirely different pieces of feedback, and cross-fading is what happens by accident when each button independently manages its own background color. This uses Framer Motion's layoutId on a single element rendered only inside the currently active button. Framer detects the same layoutId appearing in a new DOM location on the next render and automatically computes a FLIP animation between the old and new position, with a spring transition rather than a fixed-duration tween so the slide feels physical rather than mechanical. Labels sit above the sliding highlight via a relative, higher z-index so they stay legible mid-transition. This is a good default over a plain tab list when the options are mutually exclusive and few enough to fit in one row without wrapping; past four or five options it starts to feel cramped compared to real tabs. The buttons themselves carry no selection semantics beyond their visual state.

Curator’s note

The sliding highlight is a single layoutId-tracked element, the same FLIP technique used by the Animated Tabs Indicator — rendering a separately-animated background per button instead would just cross-fade opacity, not produce a real sliding pill.

Related

Pairs well with

Matched on shared tags and category — the entries most likely to be used alongside this one.

NewPro

A segmented control where only the selected option expands to its label.

segmentedtogglemorph

A tab row where the active-state highlight slides between tabs instead of popping.

tabsindicatorlayoutId

A monthly/yearly billing switch with the price sliding to its new value.

pricingtoggleswitch