Animated Tabs with Sliding Highlight and Crossfade

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.

Code

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

When to use it

How to customize it

Accessibility

Questions

How does the sliding tab highlight work?

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.

How is the content crossfade built?

`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.

How does the panel know which direction to slide?

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.

Can I use more than one set of these tabs on a page?

Yes. The `layoutId` and the element ids are prefixed with `useId()`, so two instances never share a highlight or collide.

Why do the tabs change by themselves?

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.

More in Navigation

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.

Product

Who It's For

Features

Resources

Company

View Sitemap