Skip to content
goodtabs UI
BrowseBrowse components

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

PropTypeDefaultDescription
countnumber | stringOptional count after the label on ScrollTabsTrigger.
value, defaultValue, onValueChangeradix Tabs propsControlled 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