A pill-shaped tab bar where one glowing highlight glides from tab to tab instead of blinking between them. The panel underneath crossfades: the old content drifts out one way with a soft blur while the new content arrives from the other, and its progress bars fill in a stagger. Until someone clicks, the tabs advance on their own and a light sweep inside the highlight shows the time left. On phones the tabs share the full width and the panel stacks into one column.
import { useEffect, useId, useRef, useState } from "react";
import type { KeyboardEvent } from "react";
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
const tabs = [
{
id: "orders",
label: "Orders",
icon: "M4 5h12M4 10h12M4 15h7",
heading: "Every ticket, in the order it should be cooked.",
body: "Dine-in, pickup and delivery land in one queue, sorted by promised time instead of the time they were rung in.",
metric: "142",
metricLabel: "Tickets tonight",
delta: "+18% on last Friday",
rows: [
{ name: "Dine-in", value: "78", fill: 0.82 },
{ name: "Pickup", value: "41", fill: 0.46 },
{ name: "Delivery", value: "23", fill: 0.27 },
],
},
{
id: "kitchen",
label: "Kitchen",
icon: "M6 3v6a2 2 0 002 2v6M10 3v6a2 2 0 01-2 2M14 3c-1.5 2-1.5 6 0 8v6",
heading: "Station screens that pace the whole line.",
body: "Grill, fry and pass each see only their part of the ticket, timed so a table's plates finish within the same minute.",
metric: "9:40",
metricLabel: "Average ticket time",
delta: "1 min 12 s faster",
rows: [
{ name: "Grill", value: "6:10", fill: 0.64 },
{ name: "Fry", value: "4:25", fill: 0.45 },
{ name: "Pass", value: "1:05", fill: 0.14 },
],
},
{
id: "stock",
label: "Stock",
icon: "M3 7l7-4 7 4v6l-7 4-7-4V7zM3 7l7 4 7-4M10 11v6",
heading: "Know you are short before the rush, not during it.",
body: "Each sale draws down its recipe. When burrata will run out before nine, the floor knows by six.",
metric: "3",
metricLabel: "Items running low",
delta: "Reorder drafted",
rows: [
{ name: "Burrata", value: "6 left", fill: 0.16 },
{ name: "Short rib", value: "11 left", fill: 0.31 },
{ name: "Focaccia", value: "40 left", fill: 0.9 },
],
},
{
id: "staff",
label: "Staff",
icon: "M7 9a3 3 0 100-6 3 3 0 000 6zM2 17c0-3 2-5 5-5s5 2 5 5M14 8a2.5 2.5 0 100-5M15 12c2 .5 3 2.5 3 5",
heading: "A rota that matches the bookings.",
body: "Shifts are built from next week's reservations, so nobody is sent home early and nobody is drowning at eight.",
metric: "31%",
metricLabel: "Labour against sales",
delta: "Inside the 33% target",
rows: [
{ name: "Floor", value: "7 on", fill: 0.7 },
{ name: "Kitchen", value: "5 on", fill: 0.5 },
{ name: "Bar", value: "2 on", fill: 0.2 },
],
},
];
// Time each tab stays up during the hands-free demo.
const CYCLE_MS = 2600;
export default function AnimatedTabsWithSlidingHighlightAndCrossfade() {
const reduced = !!useReducedMotion();
const uid = useId();
const root = useRef<HTMLElement>(null);
const buttons = useRef<(HTMLButtonElement | null)[]>([]);
const [state, setState] = useState({ index: 0, dir: 1 });
const [auto, setAuto] = useState(true);
const [visible, setVisible] = useState(false);
const playing = auto && visible && !reduced;
const active = state.index;
const tab = tabs[active];
const select = (index: number) => setState((s) => (index === s.index ? s : { index, dir: index > s.index ? 1 : -1 }));
useEffect(() => {
const el = root.current;
if (!el) return;
const io = new IntersectionObserver(([entry]) => setVisible(entry.isIntersecting), { threshold: 0.3 });
io.observe(el);
return () => io.disconnect();
}, []);
useEffect(() => {
if (!playing) return;
const timer = window.setTimeout(() => {
if (!document.hidden) setState((s) => ({ index: (s.index + 1) % tabs.length, dir: 1 }));
}, CYCLE_MS);
return () => window.clearTimeout(timer);
}, [playing, state]);
const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
const move = e.key === "ArrowRight" ? 1 : e.key === "ArrowLeft" ? -1 : 0;
const next = e.key === "Home" ? 0 : e.key === "End" ? tabs.length - 1 : move ? (active + move + tabs.length) % tabs.length : -1;
if (next < 0) return;
e.preventDefault();
setAuto(false);
select(next);
buttons.current[next]?.focus();
};
return (
<section ref={root} className="relative overflow-hidden bg-stone-950 px-4 py-14 sm:py-20">
<div aria-hidden="true" className="pointer-events-none absolute left-1/2 top-0 h-72 w-[640px] -translate-x-1/2 rounded-full bg-amber-500/20 blur-3xl" />
<div className="relative mx-auto max-w-4xl">
<div className="text-center">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-amber-400">Pantry for restaurants</p>
<h2 className="mx-auto mt-3 max-w-xl text-3xl font-semibold tracking-tight text-white sm:text-4xl">One screen for the pass, the line and the back office.</h2>
</div>
<div className="mt-9 flex justify-center">
<div role="tablist" aria-label="Pantry features" onKeyDown={onKeyDown} className="flex w-full rounded-full border border-white/10 bg-stone-900 p-1 sm:w-auto">
{tabs.map((t, i) => {
const on = i === active;
return (
<button
key={t.id}
ref={(node) => {
buttons.current[i] = node;
}}
type="button"
role="tab"
id={`${uid}-tab-${t.id}`}
aria-selected={on}
aria-controls={`${uid}-panel-${t.id}`}
tabIndex={on ? 0 : -1}
onClick={() => {
setAuto(false);
select(i);
}}
className={`relative flex h-11 flex-1 items-center justify-center gap-2 rounded-full px-2 text-sm font-semibold transition-colors duration-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-300 focus-visible:ring-offset-2 focus-visible:ring-offset-stone-900 sm:flex-none sm:px-5 ${on ? "text-stone-950" : "text-stone-300 hover:text-white"}`}
>
{on && (
<motion.span
layoutId={`${uid}-highlight`}
className="absolute inset-0 overflow-hidden rounded-full bg-amber-400 shadow-[0_0_28px_rgba(251,191,36,0.45)]"
transition={reduced ? { duration: 0 } : { type: "spring", stiffness: 420, damping: 32, mass: 0.9 }}
>
{playing && (
<motion.span
key={active}
className="absolute inset-0 origin-left bg-white/25"
initial={{ scaleX: 0 }}
animate={{ scaleX: 1 }}
transition={{ duration: CYCLE_MS / 1000, ease: "linear" }}
/>
)}
</motion.span>
)}
<svg viewBox="0 0 20 20" className="relative hidden h-4 w-4 sm:block" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d={t.icon} />
</svg>
<span className="relative">{t.label}</span>
</button>
);
})}
</div>
</div>
{/* Both panels sit in the same grid cell while they swap, which is what makes it a crossfade. */}
<div className="mt-8 grid overflow-hidden rounded-3xl border border-white/10 bg-stone-900">
<AnimatePresence initial={false} custom={state.dir}>
<motion.div
key={tab.id}
role="tabpanel"
id={`${uid}-panel-${tab.id}`}
aria-labelledby={`${uid}-tab-${tab.id}`}
custom={state.dir}
variants={{
enter: (dir: number) => (reduced ? { opacity: 1 } : { opacity: 0, x: dir * 36, filter: "blur(8px)" }),
show: { opacity: 1, x: 0, filter: "blur(0px)" },
leave: (dir: number) => (reduced ? { opacity: 0 } : { opacity: 0, x: dir * -36, filter: "blur(8px)" }),
}}
initial="enter"
animate="show"
exit="leave"
transition={reduced ? { duration: 0 } : { type: "spring", stiffness: 210, damping: 26 }}
className="col-start-1 row-start-1 grid gap-8 p-6 sm:p-10 md:grid-cols-2 md:items-center"
>
<div>
<h3 className="text-2xl font-semibold leading-tight tracking-tight text-white sm:text-3xl">{tab.heading}</h3>
<p className="mt-4 text-base leading-7 text-stone-300">{tab.body}</p>
<a href="#" className="mt-6 inline-flex h-11 items-center gap-2 rounded-full bg-white/10 px-5 text-sm font-semibold text-white transition-colors hover:bg-white/15 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-300">
See {tab.label.toLowerCase()} in action
<svg viewBox="0 0 20 20" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M4 10h12M11 5l5 5-5 5" />
</svg>
</a>
</div>
<div className="rounded-2xl border border-white/10 bg-stone-950 p-5 sm:p-6">
<p className="text-xs font-medium uppercase tracking-widest text-stone-400">{tab.metricLabel}</p>
<div className="mt-2 flex flex-wrap items-end justify-between gap-x-4 gap-y-2">
<p className="text-5xl font-semibold tabular-nums tracking-tight text-white">{tab.metric}</p>
<p className="mb-1 rounded-full bg-amber-400/15 px-3 py-1 text-xs font-semibold text-amber-300">{tab.delta}</p>
</div>
<ul className="mt-6 space-y-4">
{tab.rows.map((row, k) => (
<li key={row.name}>
<div className="flex justify-between text-sm">
<span className="text-stone-300">{row.name}</span>
<span className="font-semibold tabular-nums text-white">{row.value}</span>
</div>
<div className="mt-2 h-2 overflow-hidden rounded-full bg-white/10">
<motion.div
className="h-full origin-left rounded-full bg-gradient-to-r from-amber-500 to-amber-300"
style={{ width: `${row.fill * 100}%` }}
initial={reduced ? false : { scaleX: 0 }}
animate={{ scaleX: 1 }}
transition={{ type: "spring", stiffness: 90, damping: 18, delay: 0.15 + k * 0.09 }}
/>
</div>
</li>
))}
</ul>
</div>
</motion.div>
</AnimatePresence>
</div>
</div>
</section>
);
}
Install with one command: npx shadcn@latest add https://we.inc/r/animated-tabs-with-sliding-highlight-and-crossfade.json
Only the selected tab renders the highlight element, and every instance shares one `layoutId`. When the selection changes, framer-motion sees an element with the same id disappear in one place and appear in another, and animates position and size between the two. This is called a shared layout animation.
`AnimatePresence` keeps the leaving panel mounted while it plays its exit. Both panels are placed in the same grid cell with `col-start-1 row-start-1`, so they overlap during the swap instead of stacking, and the container never jumps in height.
The state stores the selected index and a direction, 1 or -1, worked out by comparing the new index with the old one. The direction is passed to the variants through the `custom` prop, including to the panel that is leaving.
Yes. The `layoutId` and the element ids are prefixed with `useId()`, so two instances never share a highlight or collide.
That is a demo cycle so a still preview shows the motion. It ends the moment a visitor picks a tab. Remove the timer effect if you do not want it.
Related: Underline Tabs with Icons and Three Panels, Floating Navbar That Shrinks to a Pill on Scroll, Self-Drawing Vertical Timeline with Pop-In Items.
More tabs and navigation or all free UI components. Start for free to have a full site built around it.
We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.