Segmented Control Swap
A filled pill travels to the segment you picked, and each label inverts as it passes underneath.
The animated component in this preview is rendered from the canonical file shown here. The surrounding demo shell only provides context and is not part of the copied code.
import { useId, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
/**
* Vibary · Segmented Control Swap
*
* The filled pill travels to the segment you picked, and each label
* inverts as it passes underneath. One pill, two positions — the shared
* layout id does the measuring.
*
* Self-contained: depends only on `motion` (react ships with your app).
* Surfaces are mixed from the inherited text color, so the control reads
* correctly on a light page and on a dark one.
* Works with zero props; tune via `variant`, `defaultIndex`.
* Click a segment, or focus one and use the arrow keys.
* Requires the automatic JSX runtime (default since React 17).
*/
export type SegmentedControlSwapProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Segment selected on first render. */
defaultIndex?: number;
/** Notified with the newly selected index. */
onChange?: (index: number) => void;
};
type VariantConfig = {
spring: { type: "spring"; stiffness: number; damping: number };
/** Seconds for a label to invert. */
invert: number;
/** Seconds before the arriving label commits to the inversion. */
invertDelay: number;
panelFade: number;
};
// Quality rule: the pill is a solid block the eye locks onto for the whole
// crossing, so any overshoot at the end is unmissable. Every spring is at
// or above a 0.8 damping ratio and lands with at most one soft settle. The
// labels only change opacity — they never scale, slide or re-weight, which
// would drag type around while the pill is already moving.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// Arrives flat. For controls that get switched constantly.
subtle: {
spring: { type: "spring", stiffness: 740, damping: 53 },
invert: 0.05,
invertDelay: 0.04,
panelFade: 0.07,
},
// One barely-there settle at the end of the crossing. All-purpose.
default: {
spring: { type: "spring", stiffness: 440, damping: 38 },
invert: 0.14,
invertDelay: 0.06,
panelFade: 0.13,
},
// A longer crossing, so the inversion is visibly handed from one label
// to the next.
playful: {
spring: { type: "spring", stiffness: 320, damping: 29 },
invert: 0.23,
invertDelay: 0.1,
panelFade: 0.17,
},
};
const ACCENT = "#7C7CF0";
/** Reads on the accent pill in either theme, so it stays literal. */
const ON_ACCENT = "#ffffff";
/** Theme-adaptive neutral: `currentColor` is the text color this component
* inherits — near-black on a light page, near-white on a dark one — so
* mixing it with `transparent` yields a surface, border or fill that is
* correctly toned in either theme. Nothing to configure. */
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
const RANGES = [
{
label: "Day",
headline: "$4,120",
caption: "Revenue today",
stats: [
["Orders", "38"],
["Refunds", "1"],
["Average", "$108"],
],
},
{
label: "Week",
headline: "$26,480",
caption: "Revenue this week",
stats: [
["Orders", "241"],
["Refunds", "9"],
["Average", "$110"],
],
},
{
label: "Month",
headline: "$112,905",
caption: "Revenue this month",
stats: [
["Orders", "1,043"],
["Refunds", "37"],
["Average", "$108"],
],
},
] as const;
export default function SegmentedControlSwap({
variant = "default",
defaultIndex = 0,
onChange,
}: SegmentedControlSwapProps) {
const [selected, setSelected] = useState(defaultIndex);
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
// Scoped per instance: a hard-coded layoutId would make two controls on
// the same page hand the pill back and forth across the document.
const pillId = useId();
// Reduced motion: the pill still marks the selection, it just stops
// travelling to get there.
const pillTransition = reduceMotion ? { duration: 0 } : cfg.spring;
const select = (index: number) => {
setSelected(index);
onChange?.(index);
};
const current = RANGES[selected];
return (
<div
style={{
width: 320,
padding: 14,
borderRadius: 16,
background: tone(6),
color: "inherit",
border: `1px solid ${tone(12)}`,
boxShadow: "0 10px 28px rgba(0,0,0,0.16)",
}}
>
<div
role="radiogroup"
aria-label="Reporting range"
style={{
display: "flex",
gap: 2,
padding: 3,
borderRadius: 11,
background: tone(8),
border: `1px solid ${tone(10)}`,
}}
>
{RANGES.map((range, index) => {
const isSelected = index === selected;
return (
<button
key={range.label}
type="button"
role="radio"
aria-checked={isSelected}
// Roving tabindex: the control is one stop in the page's tab
// order, arrow keys move within it.
tabIndex={isSelected ? 0 : -1}
onClick={() => select(index)}
onKeyDown={(event) => {
const last = RANGES.length - 1;
let next = -1;
if (event.key === "ArrowRight") next = selected === last ? 0 : selected + 1;
else if (event.key === "ArrowLeft") next = selected === 0 ? last : selected - 1;
else if (event.key === "Home") next = 0;
else if (event.key === "End") next = last;
if (next === -1) return;
event.preventDefault();
select(next);
const sibling = event.currentTarget.parentElement?.children[next];
if (sibling instanceof HTMLElement) sibling.focus();
}}
style={{
position: "relative",
flex: 1,
padding: "8px 4px",
borderRadius: 9,
border: 0,
background: "transparent",
color: "inherit",
fontFamily: "inherit",
cursor: "pointer",
}}
>
{isSelected && (
<motion.span
aria-hidden
// The whole pattern in one prop: the pill is unmounted
// from the old segment and mounted in the new one, and
// the shared id makes Motion measure both boxes and
// animate between them instead of cross-fading two pills.
layoutId={pillId}
transition={pillTransition}
style={{
position: "absolute",
inset: 0,
borderRadius: 9,
background: ACCENT,
boxShadow: "0 2px 8px rgba(0,0,0,0.18)",
}}
/>
)}
{/* Both label copies occupy the same grid cell, so they share
identical metrics and the swap is pure opacity — nothing
shifts by a subpixel as the colour changes hands. */}
<span
style={{
position: "relative",
display: "grid",
fontSize: 12.5,
fontWeight: 600,
lineHeight: 1.3,
}}
>
<motion.span
initial={false}
animate={{ opacity: isSelected ? 0 : 0.7 }}
transition={{ duration: cfg.invert, ease: "easeOut" }}
style={{ gridArea: "1 / 1" }}
>
{range.label}
</motion.span>
<motion.span
aria-hidden
initial={false}
animate={{ opacity: isSelected ? 1 : 0 }}
// The arriving label waits a beat so the inversion reads
// as the pill passing under it, not as a colour flick.
transition={{
duration: cfg.invert,
ease: "easeOut",
delay: isSelected && !reduceMotion ? cfg.invertDelay : 0,
}}
style={{ gridArea: "1 / 1", color: ON_ACCENT }}
>
{range.label}
</motion.span>
</span>
</button>
);
})}
</div>
{/* The panel cross-fades in place: the pill is the only thing that
should be seen travelling, and a sliding panel would drag its own
numbers sideways. */}
<div style={{ minHeight: 104, paddingTop: 14 }}>
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={selected}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{
duration: reduceMotion ? 0.1 : cfg.panelFade,
ease: "easeOut",
}}
>
<div style={{ fontSize: 11.5, opacity: 0.5 }}>{current.caption}</div>
<div style={{ fontSize: 26, fontWeight: 650, marginTop: 2 }}>
{current.headline}
</div>
<div style={{ display: "flex", gap: 8, marginTop: 12 }}>
{current.stats.map(([field, value]) => (
<div
key={field}
style={{
flex: 1,
padding: "8px 10px",
borderRadius: 11,
background: tone(8),
border: `1px solid ${tone(10)}`,
}}
>
<div style={{ fontSize: 10.5, opacity: 0.5 }}>{field}</div>
<div style={{ fontSize: 13, fontWeight: 600, marginTop: 2 }}>
{value}
</div>
</div>
))}
</div>
</motion.div>
</AnimatePresence>
</div>
</div>
);
}About this pattern
The two-or-three-way switch that sits above a set of figures. A shared layout id is the whole mechanism: the pill is unmounted from the old segment and mounted in the new one, and Motion measures both boxes and animates between them rather than cross-fading two pills. What lifts it above a plain indicator is the handover — the arriving label waits a beat before it inverts, so the colour change reads as the pill passing under the type rather than a flick unrelated to the movement. Both label copies share one grid cell so their metrics are identical and the swap is pure opacity, with nothing shifting by a subpixel; the panel below cross-fades in place, because a solid block travelling is the only thing worth watching here.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Mobile navigation
The selection block slides between segments and the labels invert.
Related patterns
- Tab Indicator SlideThe active-tab underline travels to the tab you picked instead of blinking out and back.
- Accordion ExpandOne section opens as the previous one closes, both heights easing over the same beat so the list never jumps.
- Breadcrumb Trail AppendGoing one level deeper slides a new crumb in from the right while the trail behind it recedes.