Scroll tabs
Underline tabs that scroll sideways with faded edges and arrows when they overflow.
npx shadcn@latest add https://ui.goodtabs.co/r/scroll-tabs.json
With the @goodtabs namespace in components.json: npx shadcn@latest add @goodtabs/scroll-tabs
Overview for northwind-web. Nothing needs your attention.
Twelve tabs in a narrow box. The edges fade and arrows appear where there is more.
Few tabs
Mira moved this to In progress 2 hours ago.
Three tabs that fit, so no fade and no arrows.
With counts
38 issues, sorted by last update.
A mono count after each label.
Usage
import {
ScrollTabs,
ScrollTabsContent,
ScrollTabsList,
ScrollTabsTrigger,
} from "@/components/ui/scroll-tabs"
<ScrollTabs defaultValue="overview">
<ScrollTabsList aria-label="Project">
<ScrollTabsTrigger value="overview">Overview</ScrollTabsTrigger>
<ScrollTabsTrigger value="logs" count={12}>Logs</ScrollTabsTrigger>
</ScrollTabsList>
<ScrollTabsContent value="overview">...</ScrollTabsContent>
<ScrollTabsContent value="logs">...</ScrollTabsContent>
</ScrollTabs>Props
| Prop | Type | Default | Description |
|---|---|---|---|
| count | number | string | Optional count after the label on ScrollTabsTrigger. | |
| value, defaultValue, onValueChange | radix Tabs props | Controlled or uncontrolled, on ScrollTabs. |
Source
scroll-tabs/scroll-tabs.tsx
"use client";
import * as React from "react";
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { cn } from "@/lib/utils";
const FADE = 40;
const reducedMotion = () =>
typeof window !== "undefined" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
/** Root. Controlled or uncontrolled through radix: value, defaultValue, onValueChange. */
function ScrollTabs({ className, ...props }: React.ComponentProps<typeof Tabs>) {
return <Tabs data-slot="scroll-tabs" className={cn("w-full gap-3", className)} {...props} />;
}
/**
* The tab row. Scrolls horizontally with a fade at each overflowing edge and an arrow
* button over the fade. The active trigger is scrolled into view when it changes.
*/
function ScrollTabsList({ className, children, ...props }: React.ComponentProps<typeof TabsList>) {
const ref = React.useRef<HTMLDivElement>(null);
const [overflow, setOverflow] = React.useState({ left: false, right: false });
React.useEffect(() => {
const list = ref.current;
if (!list) return;
const measure = () => {
const max = list.scrollWidth - list.clientWidth;
const left = list.scrollLeft > 1;
const right = list.scrollLeft < max - 1;
setOverflow((prev) => (prev.left === left && prev.right === right ? prev : { left, right }));
};
const reveal = () => {
const active = list.querySelector<HTMLElement>('[role="tab"][data-state="active"]');
if (!active) return;
const start = active.offsetLeft - FADE;
const end = active.offsetLeft + active.offsetWidth + FADE - list.clientWidth;
const left = list.scrollLeft < end ? end : list.scrollLeft > start ? start : null;
if (left !== null) list.scrollTo({ left, behavior: reducedMotion() ? "auto" : "smooth" });
};
measure();
reveal();
list.addEventListener("scroll", measure, { passive: true });
const resize = new ResizeObserver(measure);
resize.observe(list);
// data-state flips on the trigger when the value changes; watch it instead of threading value through.
const mutation = new MutationObserver(reveal);
mutation.observe(list, { attributes: true, subtree: true, attributeFilter: ["data-state"] });
return () => {
list.removeEventListener("scroll", measure);
resize.disconnect();
mutation.disconnect();
};
}, []);
const scrollBy = (direction: -1 | 1) => {
const list = ref.current;
if (!list) return;
list.scrollBy({ left: direction * list.clientWidth * 0.6, behavior: reducedMotion() ? "auto" : "smooth" });
};
return (
<div data-slot="scroll-tabs-list" className={cn("relative w-full border-b", className)}>
<TabsList
ref={ref}
variant="line"
className={cn(
"flex w-full [scrollbar-width:none] justify-start gap-3 overflow-x-auto rounded-none p-0 group-data-horizontal/tabs:h-9 [&::-webkit-scrollbar]:hidden",
overflow.left && "mask-l-from-[calc(100%-40px)]",
overflow.right && "mask-r-from-[calc(100%-40px)]",
)}
{...props}
>
{children}
</TabsList>
{overflow.left && (
<Button
type="button"
variant="ghost"
size="icon-sm"
tabIndex={-1}
aria-label="Scroll tabs left"
onClick={() => scrollBy(-1)}
className="absolute top-1 left-0 rounded-full"
>
<ChevronLeftIcon aria-hidden="true" />
</Button>
)}
{overflow.right && (
<Button
type="button"
variant="ghost"
size="icon-sm"
tabIndex={-1}
aria-label="Scroll tabs right"
onClick={() => scrollBy(1)}
className="absolute top-1 right-0 rounded-full"
>
<ChevronRightIcon aria-hidden="true" />
</Button>
)}
</div>
);
}
type TriggerProps = React.ComponentProps<typeof TabsTrigger> & {
/** Optional count shown after the label. */
count?: number | string;
};
/** An underline tab: 36px tall, muted until active, then foreground with a 2px line. */
function ScrollTabsTrigger({ className, count, children, ...props }: TriggerProps) {
return (
<TabsTrigger
data-slot="scroll-tabs-trigger"
className={cn(
"h-9 flex-none shrink-0 rounded-none px-1 py-0 text-sm text-muted-foreground transition-colors duration-100 ease-out group-data-horizontal/tabs:after:bottom-0 focus-visible:rounded-sm focus-visible:ring-2 data-active:text-foreground",
className,
)}
{...props}
>
{children}
{count !== undefined && (
<span className="font-mono text-xs font-normal text-muted-foreground" aria-label={`${count} items`}>
{count}
</span>
)}
</TabsTrigger>
);
}
function ScrollTabsContent({ className, ...props }: React.ComponentProps<typeof TabsContent>) {
return (
<TabsContent
data-slot="scroll-tabs-content"
className={cn("outline-none focus-visible:ring-2 focus-visible:ring-ring/50", className)}
{...props}
/>
);
}
export { ScrollTabs, ScrollTabsList, ScrollTabsTrigger, ScrollTabsContent };
For AI agents
Hand your agent this page, the markdown at /components/scroll-tabs/prompt.md, or the Copy AI prompt button above.
When to use
- Six or more tabs in a column that cannot grow, such as a project or settings page
- Tab rows that must work on phones without wrapping to a second line
- When the active tab should always end up visible, even after a route change
When not to use
- For two to five short tabs, segmented tabs or plain shadcn tabs are lighter
- For page-level navigation between routes, use links or the navbar
- When the tabs can wrap: a wrapped row is easier to scan than a scrolled one
Agent notes
- It is shadcn Tabs underneath: value, defaultValue and onValueChange work as usual
- The list is w-full and scrolls inside its parent, so give the parent a width or max-width
- The arrows are mouse helpers and sit outside the tab order; arrow keys already move between tabs
- Fades are mask-image gradients, so they work on any background without a matching colour