Segmented Control
An iOS-style pill switch whose highlight slides between options.
- Category
- Components
- Added
- 2026-08-23
- Deps
- 1
- Updated
- 2026-09-01
Props
"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>
);
}
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.
Pairs well with
Matched on shared tags and category — the entries most likely to be used alongside this one.
Segmented Morph Toggle
componentsA segmented control where only the selected option expands to its label.
Animated Tabs Indicator
componentsA tab row where the active-state highlight slides between tabs instead of popping.
Pricing Toggle Switch
componentsA monthly/yearly billing switch with the price sliding to its new value.