Animated Tabs Indicator
Tabs whose indicator stretches between tabs: the leading edge leaves first and the trailing edge follows on a softer spring. Labels flip colour exactly where the pill passes, hover shows a ghost highlight, optional panels slide by direction. No dependencies.
DOM + CSS0 deps1 file · 12 KB TSXreduced-motion aware
Props
"use client";
import { useEffect, useId, useRef, useState } from "react";
import type { KeyboardEvent, PointerEvent, ReactNode } from "react";
const DEFAULT_TABS = ["Overview", "Pricing", "Reviews"];
const SAFE_COLOR = /^(#[0-9a-f]{3,8}|[a-z]{3,20})$/i;
// Two springs per travel: the leading edge is stiff and fast, the trailing edge is soft and
// late, so the pill stretches across the gap and then catches up. [stiffness, damping]
const LEAD: [number, number] = [520, 36];
const TRAIL: [number, number] = [160, 22];
const TRACK = {
light: { bg: "#f5f5f5", border: "#e5e5e5", text: "#737373", hover: "#262626", ghost: "rgba(0,0,0,0.08)" },
dark: { bg: "rgba(255,255,255,0.05)", border: "rgba(255,255,255,0.1)", text: "rgba(255,255,255,0.55)", hover: "#ffffff", ghost: "rgba(255,255,255,0.11)" },
} as const;
type Edge = { x: number; v: number; t: number; k: number; c: number };
/**
* Tabs with a sliding indicator that stretches between tabs: the edge facing the destination
* leaves first, the other edge follows on a softer spring. The label colour flips exactly
* where the pill passes (a clipped copy of the labels rides inside the pill). Optional
* `panels` crossfade and slide in the direction of travel. Roving tabindex, arrow keys,
* Home and End. Hand-written springs, no dependencies.
*/
export function AnimatedTabs({
tabs = DEFAULT_TABS,
shape = "pill",
panels,
defaultIndex = 0,
onChange,
accent = "#ef4444",
accentText = "#ffffff",
tone = "light",
className = "",
ariaLabel = "Tabs",
}: {
tabs?: string[];
shape?: "pill" | "rounded";
panels?: ReactNode[];
defaultIndex?: number;
onChange?: (index: number, tab: string) => void;
accent?: string;
accentText?: string;
tone?: "light" | "dark";
className?: string;
ariaLabel?: string;
}) {
const uid = useId().replace(/[^a-zA-Z0-9]/g, "");
const count = tabs.length;
const [picked, setPicked] = useState(defaultIndex);
const active = Math.max(0, Math.min(picked, count - 1));
const tabsKey = tabs.join("\u0001");
const hasPanels = !!panels;
const accentColor = SAFE_COLOR.test(accent) ? accent : "#ef4444";
const accentInk = SAFE_COLOR.test(accentText) ? accentText : "#ffffff";
const c = TRACK[tone === "dark" ? "dark" : "light"];
const round = shape === "pill" ? "rounded-full" : "rounded-lg";
const roundFocus = shape === "pill" ? "focus-visible:!rounded-full" : "focus-visible:!rounded-lg";
const listRef = useRef<HTMLDivElement>(null);
const btnRefs = useRef<(HTMLButtonElement | null)[]>([]);
const indRef = useRef<HTMLDivElement>(null);
const cloneRef = useRef<HTMLDivElement>(null);
const ghostRef = useRef<HTMLDivElement>(null);
const engine = useRef<{ go: (i: number, snap: boolean) => void } | null>(null);
const activeRef = useRef(active);
const onChangeRef = useRef(onChange);
useEffect(() => {
activeRef.current = active;
onChangeRef.current = onChange;
});
// The spring engine: one rAF loop that sleeps at rest. Everything it touches is written to the DOM directly.
useEffect(() => {
const list = listRef.current;
const ind = indRef.current;
const clone = cloneRef.current;
if (!list || !ind || !clone) return;
const L: Edge = { x: 0, v: 0, t: 0, k: LEAD[0], c: LEAD[1] };
const R: Edge = { x: 0, v: 0, t: 0, k: LEAD[0], c: LEAD[1] };
let top = 0;
let height = 0;
let shown = false;
let raf = 0;
let last = 0;
const reduced = () => window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const write = () => {
const w = Math.max(0, R.x - L.x);
const rest = (btnRefs.current[activeRef.current]?.offsetWidth ?? w) || 1;
const stretch = Math.max(0, Math.min(1, (w - rest) / (rest * 1.4)));
ind.style.transform = "translate3d(" + L.x.toFixed(2) + "px," + top + "px,0) scale(1," + (1 - stretch * 0.1).toFixed(3) + ")";
ind.style.width = w.toFixed(2) + "px";
ind.style.height = height + "px";
clone.style.transform = "translate3d(" + (-L.x).toFixed(2) + "px," + -top + "px,0)";
if (!shown) {
shown = true;
ind.style.visibility = "visible";
}
};
const measure = (i: number) => {
const b = btnRefs.current[i];
if (!b) return false;
L.t = b.offsetLeft;
R.t = b.offsetLeft + b.offsetWidth;
top = b.offsetTop;
height = b.offsetHeight;
return true;
};
const step = (dt: number) => {
for (const e of [L, R]) {
e.v += (-e.k * (e.x - e.t) - e.c * e.v) * dt;
e.x += e.v * dt;
}
};
const tick = (now: number) => {
const dt = Math.min(0.05, (now - last) / 1000);
last = now;
const n = Math.max(1, Math.ceil(dt / 0.004));
for (let s = 0; s < n; s++) step(dt / n);
const done = [L, R].every((e) => Math.abs(e.x - e.t) < 0.02 && Math.abs(e.v) < 0.05);
if (done) {
L.x = L.t;
R.x = R.t;
L.v = R.v = 0;
write();
raf = 0;
return;
}
write();
raf = requestAnimationFrame(tick);
};
const wake = () => {
if (!raf) {
last = performance.now();
raf = requestAnimationFrame(tick);
}
};
engine.current = {
go(i, snap) {
const oldL = L.t;
if (!measure(i)) return;
if (snap || !shown || reduced()) {
L.x = L.t;
R.x = R.t;
L.v = R.v = 0;
write();
return;
}
const right = L.t >= oldL;
const lead = right ? R : L;
const trail = right ? L : R;
[lead.k, lead.c] = LEAD;
[trail.k, trail.c] = TRAIL;
wake();
},
};
engine.current.go(activeRef.current, true);
const ro = new ResizeObserver(() => engine.current?.go(activeRef.current, true));
ro.observe(list);
for (const b of btnRefs.current) if (b) ro.observe(b);
const fonts = document.fonts;
if (fonts) void fonts.ready.then(() => engine.current?.go(activeRef.current, true));
return () => {
ro.disconnect();
if (raf) cancelAnimationFrame(raf);
engine.current = null;
};
}, [tabsKey, shape]);
useEffect(() => {
engine.current?.go(active, false);
}, [active]);
const choose = (i: number) => {
if (i === active) return;
setPicked(i);
onChangeRef.current?.(i, tabs[i] ?? "");
};
const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
const key = e.key;
let next = -1;
if (key === "ArrowRight" || key === "ArrowDown") next = (active + 1) % count;
else if (key === "ArrowLeft" || key === "ArrowUp") next = (active - 1 + count) % count;
else if (key === "Home") next = 0;
else if (key === "End") next = count - 1;
if (next < 0) return;
e.preventDefault();
choose(next);
btnRefs.current[next]?.focus();
};
const showGhost = (i: number, e: PointerEvent<HTMLButtonElement>) => {
const g = ghostRef.current;
const b = btnRefs.current[i];
if (!g || !b || e.pointerType !== "mouse" || i === active) return;
const wasHidden = g.style.opacity !== "1";
if (wasHidden) g.style.transition = "none";
g.style.transform = "translate3d(" + b.offsetLeft + "px," + b.offsetTop + "px,0)";
g.style.width = b.offsetWidth + "px";
g.style.height = b.offsetHeight + "px";
if (wasHidden) void g.offsetWidth;
g.style.transition = "";
g.style.opacity = "1";
};
const hideGhost = () => {
if (ghostRef.current) ghostRef.current.style.opacity = "0";
};
const labelBox =
"flex shrink-0 items-center justify-center whitespace-nowrap px-[var(--atb-px,16px)] h-[var(--atb-h,40px)] text-[length:var(--atb-fs,14px)] font-semibold leading-none " + round;
const bar = (
<div
ref={listRef}
role="tablist"
aria-label={ariaLabel}
onKeyDown={onKeyDown}
onPointerLeave={hideGhost}
className={
"relative inline-flex max-w-full select-none gap-1 border p-1 " +
(shape === "pill" ? "rounded-full" : "rounded-xl") +
(hasPanels ? " self-start" : " " + className)
}
style={{ background: c.bg, borderColor: c.border, WebkitTapHighlightColor: "transparent" }}
>
<div
ref={ghostRef}
aria-hidden
className={"pointer-events-none absolute left-0 top-0 opacity-0 transition-[opacity] duration-200 motion-reduce:transition-none " + round}
style={{ background: c.ghost, willChange: "transform, width" }}
/>
{tabs.map((tab, i) => {
const on = i === active;
return (
<button
key={tab + "\u0001" + i}
ref={(el) => {
btnRefs.current[i] = el;
}}
id={"atb" + uid + "t" + i}
type="button"
role="tab"
aria-selected={on}
aria-controls={hasPanels ? "atb" + uid + "p" + i : undefined}
tabIndex={on ? 0 : -1}
onClick={() => choose(i)}
onPointerEnter={(e) => showGhost(i, e)}
className={
"group relative cursor-pointer border-0 bg-transparent p-0 transition-colors duration-200 focus-visible:!outline-2 focus-visible:!outline-offset-2 focus-visible:!outline-sky-500 " +
round +
" " +
roundFocus
}
style={{ color: c.text }}
onMouseEnter={(e) => {
if (!on) e.currentTarget.style.color = c.hover;
}}
onMouseLeave={(e) => {
e.currentTarget.style.color = c.text;
}}
>
<span className={labelBox + " transition-transform duration-150 ease-out group-active:scale-[0.94] motion-reduce:transition-none"}>{tab}</span>
</button>
);
})}
<div
ref={indRef}
aria-hidden
className={"pointer-events-none absolute left-0 top-0 " + round}
style={{
visibility: "hidden",
willChange: "transform, width",
transformOrigin: "50% 50%",
boxShadow:
"0 6px 16px -6px color-mix(in srgb, " + accentColor + " 70%, transparent), 0 1px 2px color-mix(in srgb, " + accentColor + " 40%, transparent)",
}}
>
<div
className={"absolute inset-0 overflow-hidden " + round}
style={{
backgroundColor: accentColor,
backgroundImage: "linear-gradient(180deg, rgba(255,255,255,0.24), rgba(255,255,255,0) 60%)",
boxShadow: "inset 0 1px 0 rgba(255,255,255,0.35), inset 0 -1px 0 rgba(0,0,0,0.12)",
}}
>
<div ref={cloneRef} className="absolute left-0 top-0 inline-flex gap-1 p-1" style={{ color: accentInk }}>
{tabs.map((tab, i) => (
<span key={tab + "\u0001" + i} className={labelBox}>
{tab}
</span>
))}
</div>
</div>
</div>
</div>
);
if (!hasPanels) return bar;
return (
<div className={"inline-flex flex-col gap-4 " + className}>
{bar}
<div className="grid">
{tabs.map((tab, i) => {
const rel = i - active;
const on = rel === 0;
return (
<div
key={tab + "\u0001" + i}
id={"atb" + uid + "p" + i}
role="tabpanel"
aria-labelledby={"atb" + uid + "t" + i}
tabIndex={on ? 0 : -1}
className="[grid-area:1/1] rounded-lg focus-visible:!outline-2 focus-visible:!outline-offset-2 focus-visible:!outline-sky-500 motion-reduce:!transform-none motion-reduce:!blur-none"
style={{
opacity: on ? 1 : 0,
visibility: on ? "visible" : "hidden",
transform: on ? "none" : "translate3d(" + (rel < 0 ? -28 : 28) + "px,0,0)",
filter: on ? "none" : "blur(4px)",
transition: on
? "opacity 380ms cubic-bezier(0.22,1,0.36,1) 60ms, transform 480ms cubic-bezier(0.22,1,0.36,1) 60ms, filter 380ms ease 60ms"
: "opacity 200ms ease, transform 300ms cubic-bezier(0.22,1,0.36,1), filter 200ms ease, visibility 0s linear 300ms",
}}
>
{panels[i] ?? null}
</div>
);
})}
</div>
</div>
);
}
Free. Pro unlocks everything, from $27/month.
Details
About this component
The active pill is measured from the real tab buttons in layout pixels and driven by one requestAnimationFrame loop that sleeps at rest. Its left and right edges are separate springs: the edge facing the destination is stiff (stiffness 520, damping 36) and the trailing edge is soft (160, 22), so the pill elongates, flattens a touch, and then catches up. A second copy of the labels rides inside the pill, which is why each label turns white at the exact moment the pill covers it rather than when the click lands. Hovering with a mouse shows a ghost highlight that glides between tabs. Pass panels to get tab panels that crossfade and slide in the direction of travel; without them the component is only the tab list, as before. It follows the tabs pattern with a tablist, roving tabindex, arrow keys, Home and End, and the tone prop switches the track between light and dark pages. Props are tabs, shape, plus optional panels, defaultIndex, onChange, accent, accentText, tone, className and ariaLabel. Sizes come from the CSS variables --atb-fs, --atb-h and --atb-px.
Curator’s note
Rebuilt in October without Framer Motion: the indicator is two edges on two springs, so it stretches across the gap instead of sliding as a rigid block.

