A pill tab bar over a card, shown as a three day trip plan. Click a tab to the right and the new panel slides in from the right while the old one leaves to the left; click back and it runs the other way. A white bubble glides between the tab labels, and the card grows or shrinks with a spring to fit each day's list. It steps through the days on its own until someone clicks. On phones the bar and card fill the width.
// Adapted from Cult UI (https://cult-ui.com/docs/components/direction-aware-tabs)
// Copyright (c) 2023 Jordan-Gilliam. MIT License: https://we.inc/components/licenses
import { useEffect, useId, useRef, useState, type KeyboardEvent } from "react";
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
import { Clock, MapPin } from "lucide-react";
const tabs = [
{
label: "Friday",
title: "Arrive and wander",
stops: [
{ time: "15:00", place: "Check in at Casa do Largo", note: "Bags can be left from noon." },
{ time: "17:30", place: "Miradouro da Graça", note: "The long view over the rooftops at golden hour." },
{ time: "20:00", place: "Dinner at Taberna Sal", note: "Booked for two. Ask for the grilled octopus." },
],
},
{
label: "Saturday",
title: "Tiles, trams and the river",
stops: [
{ time: "09:30", place: "Tile museum", note: "Allow ninety minutes." },
{ time: "12:00", place: "Tram 28 to Estrela", note: "Board at the first stop to get a seat." },
{ time: "14:00", place: "Lunch at the market hall", note: "Share plates from three stalls." },
{ time: "18:00", place: "Ferry to Cacilhas", note: "Ten minutes across, sunset on the way back." },
],
},
{
label: "Sunday",
title: "Slow morning, late flight",
stops: [
{ time: "10:00", place: "Pastries at Fábrica Dourada", note: "Still warm before eleven." },
{ time: "16:40", place: "Airport transfer", note: "Driver meets you at the front door." },
],
},
];
export default function DirectionAwareTabsWithSlidingPanels() {
const reduce = useReducedMotion();
const id = useId();
const [active, setActive] = useState(0);
const [direction, setDirection] = useState(1);
const [auto, setAuto] = useState(true);
const [height, setHeight] = useState<number | "auto">("auto");
const measure = useRef<HTMLDivElement>(null);
const tabRefs = useRef<(HTMLButtonElement | null)[]>([]);
// Track the height of the panel so the card can spring to fit it.
useEffect(() => {
const el = measure.current;
if (!el) return;
const ro = new ResizeObserver(() => setHeight(el.offsetHeight));
ro.observe(el);
return () => ro.disconnect();
}, []);
const go = (next: number) => {
if (next === active) return;
setDirection(next > active ? 1 : -1);
setActive(next);
};
// Step through the tabs until the visitor takes over.
useEffect(() => {
if (!auto || reduce) return;
const t = window.setTimeout(() => go((active + 1) % tabs.length), 2800);
return () => window.clearTimeout(t);
});
const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
const step = e.key === "ArrowRight" ? 1 : e.key === "ArrowLeft" ? -1 : 0;
if (!step) return;
e.preventDefault();
const next = (active + step + tabs.length) % tabs.length;
setAuto(false);
go(next);
tabRefs.current[next]?.focus();
};
const variants = {
enter: (d: number) => (reduce ? { opacity: 0 } : { x: 300 * d, opacity: 0, filter: "blur(4px)" }),
center: { x: 0, opacity: 1, filter: "blur(0px)" },
exit: (d: number) => (reduce ? { opacity: 0 } : { x: -300 * d, opacity: 0, filter: "blur(4px)" }),
};
const spring = reduce ? { duration: 0 } : { type: "spring" as const, bounce: 0.2, duration: 0.45 };
const tab = tabs[active];
return (
<section className="bg-orange-50 px-4 py-16 sm:px-6 sm:py-20">
<div className="mx-auto max-w-xl">
<div className="text-center">
<p className="text-sm font-semibold uppercase tracking-widest text-orange-700">Lisbon, three days</p>
<h2 className="mt-3 text-3xl font-semibold tracking-tight text-stone-900 sm:text-4xl">Your weekend, hour by hour</h2>
</div>
<div className="mt-8 flex justify-center">
<div role="tablist" aria-label="Day of the trip" onKeyDown={onKeyDown} className="inline-flex gap-1 rounded-full bg-stone-900 p-1 shadow-inner">
{tabs.map((t, i) => (
<button
key={t.label}
ref={(el) => {
tabRefs.current[i] = el;
}}
type="button"
role="tab"
id={`${id}-tab-${i}`}
aria-selected={active === i}
aria-controls={`${id}-panel`}
tabIndex={active === i ? 0 : -1}
onClick={() => {
setAuto(false);
go(i);
}}
className={`relative h-10 rounded-full px-4 text-sm font-medium transition-colors duration-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-orange-400 focus-visible:ring-offset-2 focus-visible:ring-offset-stone-900 sm:px-5 ${
active === i ? "text-stone-900" : "text-stone-300 hover:text-white"
}`}
>
{active === i && <motion.span layoutId={`${id}-bubble`} transition={reduce ? { duration: 0 } : { type: "spring", bounce: 0.19, duration: 0.4 }} className="absolute inset-0 rounded-full bg-white shadow" />}
<span className="relative">{t.label}</span>
</button>
))}
</div>
</div>
<div className="mt-6 min-h-[350px]">
<motion.div initial={false} animate={{ height }} transition={spring} className="relative overflow-hidden rounded-3xl border border-stone-200 bg-white shadow-xl shadow-orange-900/5">
<div ref={measure} role="tabpanel" id={`${id}-panel`} aria-labelledby={`${id}-tab-${active}`} className="p-6 sm:p-8">
<AnimatePresence custom={direction} mode="popLayout" initial={false}>
<motion.div key={active} custom={direction} variants={variants} initial="enter" animate="center" exit="exit" transition={spring}>
<h3 className="text-xl font-semibold text-stone-900">{tab.title}</h3>
<ol className="mt-5 space-y-4">
{tab.stops.map((s) => (
<li key={s.place} className="flex gap-4">
<span className="flex h-7 w-16 shrink-0 items-center justify-center gap-1 rounded-full bg-orange-100 text-xs font-semibold tabular-nums text-orange-800">
<Clock aria-hidden="true" className="h-3 w-3" />
{s.time}
</span>
<span>
<span className="flex items-center gap-1.5 text-sm font-semibold text-stone-900">
<MapPin aria-hidden="true" className="h-3.5 w-3.5 text-orange-600" />
{s.place}
</span>
<span className="mt-0.5 block text-sm leading-6 text-stone-600">{s.note}</span>
</span>
</li>
))}
</ol>
</motion.div>
</AnimatePresence>
</div>
</motion.div>
</div>
</div>
</section>
);
}
Install with one command: npx shadcn@latest add https://we.inc/r/direction-aware-tabs-with-sliding-panels.json
When a tab is chosen its index is compared with the current one. A higher index sets `direction` to 1, a lower one to -1. That number is passed to Framer Motion as `custom`, and the enter and exit variants multiply their `x` offset by it.
A `ResizeObserver` watches the inner panel and stores its height. The outer card animates to that number with a spring, so it follows the content whatever is inside, including text that wraps differently on a phone.
`AnimatePresence` runs in `popLayout` mode, which lifts the leaving panel out of the layout as it exits. The two panels cross over in the same space.
Yes, add entries to `tabs`. On narrow screens keep the labels short, or reduce `px-4` on the buttons, so the bar stays on one line.
No. The original idea used a measuring hook from a package; here it is a few lines with the browser's own `ResizeObserver`.
Adapted from Direction Aware Tabs in Cult UI, used under its open-source licence. Licences and credits
Related: Animated Tabs with Sliding Highlight and Crossfade, Segmented Control with Spring Pill and Morphing Views, Underline Tabs with Icons and Three Panels.
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.