Scrollable Tab
Original · freehorizontally scrollable tabs
byWeb InnoventixReact + Tailwind
tabscrollabletabs
Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.
npx shadcn@latest add https://webinnoventix.com/r/tab-scrollable.jsontab-scrollable.tsx
"use client";
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type KeyboardEvent,
} from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
type Accent = "indigo" | "emerald" | "amber" | "rose" | "sky" | "violet";
type Story = { title: string; meta: string };
type NewsTab = {
id: string;
label: string;
accent: Accent;
kicker: string;
featured: { title: string; dek: string; byline: string; read: string };
more: Story[];
};
const ACCENT: Record<
Accent,
{ dot: string; bar: string; kicker: string; edge: string }
> = {
indigo: {
dot: "bg-indigo-400",
bar: "bg-indigo-500",
kicker: "text-indigo-600 dark:text-indigo-400",
edge: "border-indigo-500",
},
emerald: {
dot: "bg-emerald-400",
bar: "bg-emerald-500",
kicker: "text-emerald-600 dark:text-emerald-400",
edge: "border-emerald-500",
},
amber: {
dot: "bg-amber-400",
bar: "bg-amber-500",
kicker: "text-amber-600 dark:text-amber-400",
edge: "border-amber-500",
},
rose: {
dot: "bg-rose-400",
bar: "bg-rose-500",
kicker: "text-rose-600 dark:text-rose-400",
edge: "border-rose-500",
},
sky: {
dot: "bg-sky-400",
bar: "bg-sky-500",
kicker: "text-sky-600 dark:text-sky-400",
edge: "border-sky-500",
},
violet: {
dot: "bg-violet-400",
bar: "bg-violet-500",
kicker: "text-violet-600 dark:text-violet-400",
edge: "border-violet-500",
},
};
const TABS: NewsTab[] = [
{
id: "top",
label: "Top Stories",
accent: "indigo",
kicker: "Front page",
featured: {
title: "Fusion startup keeps plasma stable for a record 43 minutes",
dek: "The milestone at the Hefei test reactor edges net-gain energy closer to a grid-ready timeline than any run before it.",
byline: "By Dana Whitfield",
read: "6 min read",
},
more: [
{ title: "City council approves overnight transit pilot on three lines", meta: "Local · 21 min ago" },
{ title: "What the new export rules mean for small manufacturers", meta: "Explainer · 1 hr ago" },
],
},
{
id: "world",
label: "World",
accent: "emerald",
kicker: "Across the globe",
featured: {
title: "Coalition talks stall as parties clash over the budget ceiling",
dek: "Negotiators in The Hague broke for the night without a deal on next year's spending cap, pushing a decision to the weekend.",
byline: "By Marcus Lindqvist",
read: "5 min read",
},
more: [
{ title: "Drought pushes Horn of Africa harvest to a decade low", meta: "Analysis · 2 hr ago" },
{ title: "Border rail link reopens after an eight-year closure", meta: "Dispatch · 3 hr ago" },
],
},
{
id: "business",
label: "Business",
accent: "amber",
kicker: "Money & work",
featured: {
title: "Retail chains bet on smaller stores as downtown rents climb",
dek: "The flagship format shrinks to 4,000 square feet as brands chase foot traffic without carrying the overhead of a full department floor.",
byline: "By Priya Nair",
read: "4 min read",
},
more: [
{ title: "Four-day week trial reports steady output after one year", meta: "Workplace · 45 min ago" },
{ title: "Freight rates cool as port backlogs finally clear", meta: "Logistics · 2 hr ago" },
],
},
{
id: "tech",
label: "Technology",
accent: "violet",
kicker: "Silicon & software",
featured: {
title: "Open model runs on a single laptop GPU without the cloud",
dek: "The three-billion-parameter release trades raw scale for on-device privacy and genuinely usable offline performance.",
byline: "By Theo Almeida",
read: "7 min read",
},
more: [
{ title: "Right-to-repair law adds phones and tablets in January", meta: "Policy · 1 hr ago" },
{ title: "Browsers move to kill the last of third-party cookies", meta: "Web · 4 hr ago" },
],
},
{
id: "science",
label: "Science",
accent: "sky",
kicker: "Discovery",
featured: {
title: "Deep-sea cameras catch a squid species never filmed alive",
dek: "Footage from 2,400 meters shows the animal hunting far shallower than population models had predicted for the region.",
byline: "By Lena Osei",
read: "5 min read",
},
more: [
{ title: "Ancient riverbed on Mars hints at a longer wet era", meta: "Space · 3 hr ago" },
{ title: "Lab-grown enamel repairs cavities in early trials", meta: "Health · 6 hr ago" },
],
},
{
id: "climate",
label: "Climate",
accent: "emerald",
kicker: "A warming world",
featured: {
title: "Grid stores a full day of solar for the first time in the region",
dek: "New iron-air batteries carried the entire evening peak without firing a single gas plant, operators confirmed on Tuesday.",
byline: "By Idris Hassan",
read: "6 min read",
},
more: [
{ title: "Reforested slopes cut landslide risk, ten-year study finds", meta: "Research · 2 hr ago" },
{ title: "Cities trade asphalt for reflective paint to beat the heat", meta: "Feature · 5 hr ago" },
],
},
{
id: "health",
label: "Health",
accent: "rose",
kicker: "Body & mind",
featured: {
title: "Blood test flags pancreatic cancer months before symptoms",
dek: "The screening spots a cluster of protein markers with 88 percent accuracy across a four-thousand-patient validation study.",
byline: "By Grace Okonkwo",
read: "5 min read",
},
more: [
{ title: "Sleep researchers link late meals to next-day focus dips", meta: "Wellness · 1 hr ago" },
{ title: "Rural clinics expand telehealth after a funding boost", meta: "Access · 3 hr ago" },
],
},
{
id: "markets",
label: "Markets",
accent: "amber",
kicker: "Trading floor",
featured: {
title: "Bond yields ease as inflation prints below forecast",
dek: "Two-year notes rallied hardest after the surprise slowdown in core prices reset bets on the next rate decision.",
byline: "By Sofia Renner",
read: "3 min read",
},
more: [
{ title: "Copper hits a two-year high on grid demand", meta: "Commodities · 30 min ago" },
{ title: "Small caps outrun the index for a third straight week", meta: "Equities · 2 hr ago" },
],
},
{
id: "culture",
label: "Culture",
accent: "violet",
kicker: "Arts & ideas",
featured: {
title: "A debut novel maps a city through its all-night diners",
dek: "Critics are calling the 320-page portrait the year's most tender study of strangers who share a counter but never a name.",
byline: "By Noah Bergstrom",
read: "6 min read",
},
more: [
{ title: "Restored silent film screens with a live 40-piece score", meta: "Film · 4 hr ago" },
{ title: "Museum opens its archive vaults to the public", meta: "Art · 5 hr ago" },
],
},
{
id: "sports",
label: "Sports",
accent: "sky",
kicker: "The scoreboard",
featured: {
title: "Underdogs stun the champions with a stoppage-time winner",
dek: "A nineteen-year-old substitute settled the semifinal in front of a sold-out stand and sent the tie to a decider abroad.",
byline: "By Amara Diallo",
read: "4 min read",
},
more: [
{ title: "Marathon record falls by 41 seconds in cool conditions", meta: "Athletics · 1 hr ago" },
{ title: "Rookie signs the richest first contract in league history", meta: "Transfers · 3 hr ago" },
],
},
{
id: "politics",
label: "Politics",
accent: "rose",
kicker: "Power & policy",
featured: {
title: "Housing bill clears committee with rare cross-party support",
dek: "The measure ties zoning reform to transit funding in fast-growing suburbs, a trade that pulled skeptics from both benches.",
byline: "By Elena Marchetti",
read: "5 min read",
},
more: [
{ title: "Redistricting map heads to the state supreme court", meta: "Courts · 2 hr ago" },
{ title: "Turnout in early voting outpaces the last cycle", meta: "Elections · 4 hr ago" },
],
},
{
id: "travel",
label: "Travel",
accent: "indigo",
kicker: "Away",
featured: {
title: "Night trains return to three routes across the Alps",
dek: "Sleeper cars are selling out for winter as flyers trade fluorescent airports for the quiet of a mountain dawn arrival.",
byline: "By Kaito Mori",
read: "4 min read",
},
more: [
{ title: "A quiet island bets on car-free tourism", meta: "Escapes · 3 hr ago" },
{ title: "Visa-free travel expands for a dozen more countries", meta: "Advisory · 6 hr ago" },
],
},
];
export default function ScrollableTabs() {
const reduce = useReducedMotion();
const [activeId, setActiveId] = useState<string>(TABS[0].id);
const [canLeft, setCanLeft] = useState(false);
const [canRight, setCanRight] = useState(false);
const scrollRef = useRef<HTMLDivElement | null>(null);
const tabRefs = useRef<Record<string, HTMLButtonElement | null>>({});
const active = useMemo(
() => TABS.find((t) => t.id === activeId) ?? TABS[0],
[activeId],
);
const accent = ACCENT[active.accent];
const updateScroll = useCallback(() => {
const el = scrollRef.current;
if (!el) return;
setCanLeft(el.scrollLeft > 4);
setCanRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 4);
}, []);
useEffect(() => {
updateScroll();
const el = scrollRef.current;
if (!el) return;
const ro = new ResizeObserver(updateScroll);
ro.observe(el);
window.addEventListener("resize", updateScroll);
return () => {
ro.disconnect();
window.removeEventListener("resize", updateScroll);
};
}, [updateScroll]);
const selectAndFocus = useCallback(
(id: string) => {
setActiveId(id);
const el = tabRefs.current[id];
if (el) {
el.focus();
el.scrollIntoView({
inline: "nearest",
block: "nearest",
behavior: reduce ? "auto" : "smooth",
});
}
},
[reduce],
);
const onTabKeyDown = useCallback(
(e: KeyboardEvent<HTMLButtonElement>, index: number) => {
const last = TABS.length - 1;
let next = -1;
if (e.key === "ArrowRight") next = index === last ? 0 : index + 1;
else if (e.key === "ArrowLeft") next = index === 0 ? last : index - 1;
else if (e.key === "Home") next = 0;
else if (e.key === "End") next = last;
if (next >= 0) {
e.preventDefault();
selectAndFocus(TABS[next].id);
}
},
[selectAndFocus],
);
const nudge = useCallback(
(dir: -1 | 1) => {
const el = scrollRef.current;
if (!el) return;
el.scrollBy({
left: dir * el.clientWidth * 0.72,
behavior: reduce ? "auto" : "smooth",
});
},
[reduce],
);
return (
<section className="relative w-full bg-slate-50 px-4 py-16 sm:py-24 dark:bg-slate-950">
<style>{`
@keyframes tabscroll-pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.35; transform: scale(0.7); }
}
.tabscroll-live { animation: tabscroll-pulse 1.8s ease-in-out infinite; }
.tabscroll-bar { scrollbar-width: none; -ms-overflow-style: none; }
.tabscroll-bar::-webkit-scrollbar { display: none; }
@media (prefers-reduced-motion: reduce) {
.tabscroll-live { animation: none; }
.tabscroll-bar { scroll-behavior: auto; }
}
`}</style>
<div className="mx-auto max-w-5xl">
<header className="mb-8 flex flex-col gap-3">
<span className="inline-flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.18em] text-indigo-600 dark:text-indigo-400">
<span className="tabscroll-live h-2 w-2 rounded-full bg-emerald-500" />
Live newsroom
</span>
<h2 className="text-2xl font-bold tracking-tight text-slate-900 sm:text-3xl dark:text-white">
The Meridian Wire
</h2>
<p className="max-w-2xl text-sm leading-relaxed text-slate-600 dark:text-slate-400">
Swipe the rail or arrow through every desk. Twelve categories,
updated as stories break — scroll sideways to find yours.
</p>
</header>
<div className="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900">
{/* Tab rail */}
<div className="flex items-stretch gap-1 border-b border-slate-200 px-1.5 dark:border-slate-800">
<button
type="button"
onClick={() => nudge(-1)}
disabled={!canLeft}
aria-label="Scroll categories left"
className="my-2 flex shrink-0 items-center justify-center rounded-lg px-1 text-slate-500 outline-none transition hover:bg-slate-100 hover:text-slate-900 focus-visible:ring-2 focus-visible:ring-indigo-500 disabled:pointer-events-none disabled:opacity-25 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-white"
>
<svg viewBox="0 0 24 24" fill="none" className="h-5 w-5" aria-hidden="true">
<path d="M15 6l-6 6 6 6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
<div className="relative min-w-0 flex-1">
<div
ref={scrollRef}
onScroll={updateScroll}
role="tablist"
aria-label="News categories"
aria-orientation="horizontal"
className="tabscroll-bar flex gap-1 overflow-x-auto scroll-smooth py-2"
>
{TABS.map((tab, i) => {
const isActive = tab.id === activeId;
const ta = ACCENT[tab.accent];
const count = tab.more.length + 1;
return (
<button
key={tab.id}
ref={(node) => {
tabRefs.current[tab.id] = node;
}}
type="button"
role="tab"
id={`tabscroll-tab-${tab.id}`}
aria-selected={isActive}
aria-controls={`tabscroll-panel-${tab.id}`}
tabIndex={isActive ? 0 : -1}
onClick={() => selectAndFocus(tab.id)}
onKeyDown={(e) => onTabKeyDown(e, i)}
className={[
"group relative flex shrink-0 items-center gap-2 rounded-lg px-3.5 py-2.5 text-sm font-medium outline-none transition-colors",
"focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900",
isActive
? "text-slate-900 dark:text-white"
: "text-slate-500 hover:text-slate-800 dark:text-slate-400 dark:hover:text-slate-200",
].join(" ")}
>
<span
className={[
"h-1.5 w-1.5 rounded-full transition-opacity",
ta.dot,
isActive ? "opacity-100" : "opacity-50 group-hover:opacity-80",
].join(" ")}
/>
<span className="whitespace-nowrap">{tab.label}</span>
<span
className={[
"rounded-full px-1.5 py-0.5 text-[11px] font-semibold tabular-nums transition-colors",
isActive
? "bg-slate-900 text-white dark:bg-white dark:text-slate-900"
: "bg-slate-100 text-slate-500 dark:bg-slate-800 dark:text-slate-400",
].join(" ")}
>
{count}
</span>
{isActive &&
(reduce ? (
<span
aria-hidden="true"
className={`pointer-events-none absolute inset-x-3 -bottom-2 h-0.5 rounded-full ${ta.bar}`}
/>
) : (
<motion.span
aria-hidden="true"
layoutId="tabscroll-indicator"
className={`pointer-events-none absolute inset-x-3 -bottom-2 h-0.5 rounded-full ${ta.bar}`}
transition={{ type: "spring", stiffness: 520, damping: 40 }}
/>
))}
</button>
);
})}
</div>
{/* Edge fades */}
<div
aria-hidden="true"
className={`pointer-events-none absolute inset-y-0 left-0 w-8 bg-gradient-to-r from-white to-transparent transition-opacity duration-200 dark:from-slate-900 ${canLeft ? "opacity-100" : "opacity-0"}`}
/>
<div
aria-hidden="true"
className={`pointer-events-none absolute inset-y-0 right-0 w-8 bg-gradient-to-l from-white to-transparent transition-opacity duration-200 dark:from-slate-900 ${canRight ? "opacity-100" : "opacity-0"}`}
/>
</div>
<button
type="button"
onClick={() => nudge(1)}
disabled={!canRight}
aria-label="Scroll categories right"
className="my-2 flex shrink-0 items-center justify-center rounded-lg px-1 text-slate-500 outline-none transition hover:bg-slate-100 hover:text-slate-900 focus-visible:ring-2 focus-visible:ring-indigo-500 disabled:pointer-events-none disabled:opacity-25 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-white"
>
<svg viewBox="0 0 24 24" fill="none" className="h-5 w-5" aria-hidden="true">
<path d="M9 6l6 6-6 6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
</div>
{/* Panel */}
<div className="p-5 sm:p-7">
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={active.id}
role="tabpanel"
id={`tabscroll-panel-${active.id}`}
aria-labelledby={`tabscroll-tab-${active.id}`}
tabIndex={0}
initial={reduce ? false : { opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={reduce ? { opacity: 0 } : { opacity: 0, y: -8 }}
transition={{ duration: reduce ? 0 : 0.24, ease: "easeOut" }}
className="outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-4 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900"
>
<div className="flex items-center justify-between gap-4">
<p className={`text-xs font-semibold uppercase tracking-[0.18em] ${accent.kicker}`}>
{active.kicker}
</p>
<span className="text-xs tabular-nums text-slate-400 dark:text-slate-500">
{active.more.length + 1} stories
</span>
</div>
<article className={`mt-4 border-l-2 pl-4 ${accent.edge}`}>
<h3 className="text-lg font-semibold leading-snug text-slate-900 sm:text-xl dark:text-white">
{active.featured.title}
</h3>
<p className="mt-2 text-sm leading-relaxed text-slate-600 dark:text-slate-400">
{active.featured.dek}
</p>
<p className="mt-3 text-xs font-medium text-slate-500 dark:text-slate-500">
{active.featured.byline} · {active.featured.read}
</p>
</article>
<ul className="mt-5 divide-y divide-slate-100 border-t border-slate-100 dark:divide-slate-800 dark:border-slate-800">
{active.more.map((story) => (
<li key={story.title} className="flex items-start gap-3 py-3">
<span className={`mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full ${accent.dot}`} />
<div className="min-w-0">
<p className="text-sm font-medium text-slate-800 dark:text-slate-200">
{story.title}
</p>
<p className="mt-0.5 text-xs text-slate-500 dark:text-slate-500">
{story.meta}
</p>
</div>
</li>
))}
</ul>
</motion.div>
</AnimatePresence>
</div>
</div>
</div>
</section>
);
}Dependencies
motion
Licence
Built by Web Innoventix. Free for personal and commercial use, no attribution required.
Built by Web Innoventix
Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.
Get a free quoteSimilar components
Browse all →
Underline Tab
Originalunderline tabs with sliding indicator

Pills Tab
Originalpill tabs

Boxed Tab
Originalboxed/segmented tabs

Vertical Tab
Originalvertical tabs
Icons Tab
Originaltabs with icons

Segmented Tab
Originalsegmented control tabs

Animated Indicator Tab
Originaltabs with a morphing indicator

Cards Tab
Originalcard-style tabs with panels

With Badge Tab
Originaltabs with count badges

Line Grow Tab
Originaltabs where the underline grows in

Closable Tab
Originalbrowser-style closable tabs

Spotlight Hero
OriginalA centred hero with a soft radial spotlight, badge and dual call-to-action.

