Tab Indicator Slide
The active-tab underline travels to the tab you picked instead of blinking out and back.
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 · Tab Indicator Slide
*
* The active-tab underline travels to the tab you picked instead of
* disappearing and reappearing — one shared element, two positions.
*
* Self-contained: depends only on `motion` (react ships with your app).
* Surfaces are mixed from the inherited text color, so the strip reads
* correctly on a light page and on a dark one.
* Works with zero props; tune via `variant`, `defaultIndex`.
* Click a tab, or focus one and use the arrow keys.
* Requires the automatic JSX runtime (default since React 17).
*/
export type TabIndicatorSlideProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Tab 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 };
labelFade: number;
panelFade: number;
};
// Quality rule: the underline is a 2px rule the eye tracks the whole way,
// so overshoot is unusually visible here — every spring is at or above a
// 0.8 damping ratio and lands with at most one soft settle. Variants
// change how fast it crosses, never how much it wobbles at the end.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// Arrives flat and almost immediately — ζ = 1.0, settled in about
// 130ms. For dense app chrome that gets clicked all day.
subtle: {
spring: { type: "spring", stiffness: 900, damping: 60 },
labelFade: 0.08,
panelFade: 0.07,
},
// One barely-there settle at the end of the travel. All-purpose.
default: {
spring: { type: "spring", stiffness: 420, damping: 36 },
labelFade: 0.16,
panelFade: 0.12,
},
// The indicator cannot travel further — the tabs decide that — so
// playful spends its energy on time instead: a softer glide that
// takes about twice as long to arrive, with the labels crossing over
// behind it. For marketing-side tabs with only a few items.
playful: {
spring: { type: "spring", stiffness: 240, damping: 28 },
labelFade: 0.26,
panelFade: 0.22,
},
};
const ACCENT = "#7C7CF0";
/** 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 SAMPLE = [
{
label: "Overview",
heading: "4 active projects",
body: "Two shipped this week. Nothing is blocked.",
},
{
label: "Activity",
heading: "18 events today",
body: "Latest: Priya moved Billing v2 into review.",
},
{
label: "Members",
heading: "9 people",
body: "3 owners, 5 editors, 1 viewer.",
},
{
label: "Settings",
heading: "Workspace",
body: "Region eu-west · Retention 90 days.",
},
] as const;
export default function TabIndicatorSlide({
variant = "default",
defaultIndex = 0,
onChange,
}: TabIndicatorSlideProps) {
const [active, setActive] = useState(defaultIndex);
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
// Scoped per instance: a hard-coded layoutId would make two tab bars on
// the same page hand the underline back and forth across the document.
const indicatorId = useId();
// Reduced motion: the underline still marks the active tab, it just
// stops travelling to get there.
const indicatorTransition = reduceMotion ? { duration: 0 } : cfg.spring;
const select = (index: number) => {
setActive(index);
onChange?.(index);
};
return (
<div
style={{
width: 340,
borderRadius: 16,
background: tone(6),
color: "inherit",
border: `1px solid ${tone(12)}`,
boxShadow: "0 10px 28px rgba(0,0,0,0.16)",
overflow: "hidden",
}}
>
<div
role="tablist"
aria-label="Workspace sections"
style={{
display: "flex",
padding: "0 6px",
borderBottom: `1px solid ${tone(12)}`,
}}
>
{SAMPLE.map((tab, index) => {
const isActive = index === active;
return (
<button
key={tab.label}
type="button"
role="tab"
id={`${indicatorId}-tab-${index}`}
aria-selected={isActive}
aria-controls={`${indicatorId}-panel`}
// Roving tabindex: the tab strip is one stop in the page's
// tab order, arrow keys move within it.
tabIndex={isActive ? 0 : -1}
onClick={() => select(index)}
onKeyDown={(event) => {
const last = SAMPLE.length - 1;
let next = -1;
if (event.key === "ArrowRight") next = active === last ? 0 : active + 1;
else if (event.key === "ArrowLeft") next = active === 0 ? last : active - 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",
padding: "12px 12px 13px",
background: "none",
border: 0,
fontSize: 13,
fontWeight: 600,
fontFamily: "inherit",
color: "inherit",
cursor: "pointer",
}}
>
{/* Opacity, not color: a fade is one compositor property and
reads identically on any palette the label inherits. */}
<motion.span
initial={false}
animate={{ opacity: isActive ? 1 : 0.45 }}
transition={{ duration: cfg.labelFade, ease: "easeOut" }}
style={{ display: "block" }}
>
{tab.label}
</motion.span>
{isActive && (
<motion.span
// The whole pattern in one prop: the underline is removed
// from the old tab and mounted under the new one, and the
// shared id makes Motion animate between the two boxes
// rather than cross-fade two separate rules.
layoutId={indicatorId}
transition={indicatorTransition}
style={{
position: "absolute",
left: 12,
right: 12,
bottom: -1,
height: 2,
borderRadius: 2,
background: ACCENT,
}}
/>
)}
</button>
);
})}
</div>
{/* The panel cross-fades in place: the underline is the only thing
that should be seen travelling, and a sliding panel would also
drag its text sideways. */}
<div
id={`${indicatorId}-panel`}
role="tabpanel"
aria-labelledby={`${indicatorId}-tab-${active}`}
style={{ minHeight: 74, padding: "16px 18px" }}
>
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={active}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: reduceMotion ? 0.1 : cfg.panelFade, ease: "easeOut" }}
>
<div style={{ fontSize: 15, fontWeight: 650 }}>
{SAMPLE[active].heading}
</div>
<div style={{ fontSize: 12.5, opacity: 0.55, marginTop: 4 }}>
{SAMPLE[active].body}
</div>
</motion.div>
</AnimatePresence>
</div>
</div>
);
}About this pattern
Orientation for tab strips, segmented filters and sub-navigation. Mounting the underline under the newly selected tab and giving it a shared layout id is the entire mechanism: Motion measures both boxes and animates the rule across the gap, so the eye follows one continuous object and always knows which way it moved. Because a 2px line is tracked the whole way, overshoot shows up here more than anywhere else — the springs stay near critical damping and the panel underneath cross-fades in place rather than sliding, which would drag its text sideways.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Mobile navigation
The selection indicator slides between segments rather than jumping.
Related patterns
- Segmented Control SwapA filled pill travels to the segment you picked, and each label inverts as it passes underneath.
- Route Progress TopA top-edge bar advances during navigation and completes with a quick finish.
- Detail Close ReturnClosing a detail view returns the tile to the exact row it came from.