Overflow Tabs
Navigation

Overflow Tabs

Tab strip that measures itself and folds overflowing tabs into a +N menu — active pill slides between tabs, with a drag-scroll fallback mode.

Install

npx shadcn@latest add @paragon/overflow-tabs

Also installs: dropdown-menu

overflow-tabs.tsx

"use client";

import * as React from "react";
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuTrigger,
} from "@/registry/paragon/ui/dropdown-menu";
import { cn } from "@/lib/utils";

export interface OverflowTab {
  value: string;
  label: string;
  icon?: React.ReactNode;
}

export interface OverflowTabsProps
  extends Omit<React.ComponentProps<"div">, "onChange"> {
  tabs: OverflowTab[];
  value?: string;
  defaultValue?: string;
  onValueChange?: (value: string) => void;
  /**
   * "collapse" folds tabs that stop fitting into a "+N" menu (the active tab
   * always stays in the strip); "scroll" keeps one row with drag-to-scroll
   * and masked edges.
   */
  overflow?: "collapse" | "scroll";
  /** Disables the sliding indicator motion. */
  static?: boolean;
}

const GAP = 2; // gap-0.5 between tab cells
const PAD = 8; // p-1 on the strip

function tabClasses(selected: boolean) {
  return cn(
    "relative z-10 inline-flex h-7 shrink-0 items-center justify-center gap-1.5 rounded-md px-3 text-sm font-medium whitespace-nowrap outline-none select-none",
    "transition-[color] duration-150 ease-out focus-visible:ring-2 focus-visible:ring-ring",
    "[&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
    selected ? "text-foreground" : "text-muted-foreground hover:text-foreground",
  );
}

/**
 * A tab strip that measures itself. In collapse mode an invisible clone
 * provides every tab's natural width; when the strip overflows, trailing
 * tabs fold into a "+N" menu — and selecting a hidden tab swaps it into the
 * strip. The active pill is a single measured indicator that slides between
 * tabs (first paint suppressed, ResizeObserver snaps without animating).
 * Arrow keys move selection across every tab, including hidden ones.
 */
export function OverflowTabs({
  tabs,
  value: valueProp,
  defaultValue,
  onValueChange,
  overflow = "collapse",
  static: isStatic = false,
  className,
  ...props
}: OverflowTabsProps) {
  const [internalValue, setInternalValue] = React.useState(
    defaultValue ?? tabs[0]?.value,
  );
  const value = valueProp ?? internalValue;
  const setValue = (next: string) => {
    setInternalValue(next);
    onValueChange?.(next);
  };

  const listRef = React.useRef<HTMLDivElement>(null);
  const rowRef = React.useRef<HTMLDivElement>(null);
  const measureRef = React.useRef<HTMLDivElement>(null);
  const indicatorRef = React.useRef<HTMLSpanElement>(null);
  const hasPositioned = React.useRef(false);
  const keyboardNav = React.useRef(false);
  const widthsRef = React.useRef<Map<string, number>>(new Map());
  const plusWidthRef = React.useRef(48);

  const [visibleValues, setVisibleValues] = React.useState<string[]>(() =>
    tabs.map((tab) => tab.value),
  );
  const [fades, setFades] = React.useState({ left: false, right: false });

  const tabsKey = tabs.map((tab) => tab.value).join(" ");
  const collapsing = overflow === "collapse";

  /* ------------------------------ measurement ----------------------------- */

  const compute = React.useCallback(() => {
    if (!collapsing) return;
    const list = listRef.current;
    if (!list) return;
    const usable = list.clientWidth - PAD;
    const widths = widthsRef.current;
    const all = tabs.map((tab) => tab.value);
    const total = all.reduce(
      (sum, v, i) => sum + (widths.get(v) ?? 0) + (i > 0 ? GAP : 0),
      0,
    );
    let next: string[];
    if (total <= usable) {
      next = all;
    } else {
      const available = usable - plusWidthRef.current - GAP;
      let used = 0;
      let count = 0;
      for (const v of all) {
        const width = (widths.get(v) ?? 0) + (count > 0 ? GAP : 0);
        if (used + width > available) break;
        used += width;
        count += 1;
      }
      next = all.slice(0, Math.max(count, 1));
      // The active tab always stays in the strip.
      if (!next.includes(value)) {
        next = [...next.slice(0, Math.max(next.length - 1, 0)), value];
      }
    }
    setVisibleValues((prev) =>
      prev.length === next.length && prev.every((v, i) => v === next[i])
        ? prev
        : next,
    );
  }, [collapsing, tabs, value]);

  React.useLayoutEffect(() => {
    const measure = measureRef.current;
    if (measure) {
      const widths = new Map<string, number>();
      for (const cell of measure.querySelectorAll<HTMLElement>(
        "[data-measure-tab]",
      )) {
        widths.set(cell.dataset.measureTab ?? "", cell.offsetWidth);
      }
      widthsRef.current = widths;
      const plus = measure.querySelector<HTMLElement>("[data-measure-plus]");
      if (plus) plusWidthRef.current = plus.offsetWidth;
    }
    compute();
  }, [compute, tabsKey]);

  /* ------------------------------- indicator ------------------------------ */

  const position = React.useCallback((animate: boolean) => {
    const row = rowRef.current;
    const indicator = indicatorRef.current;
    if (!row || !indicator) return;
    const active = row.querySelector<HTMLElement>(
      '[role="tab"][aria-selected="true"]',
    );
    if (!active) {
      indicator.style.opacity = "0";
      return;
    }
    if (!animate) indicator.style.transitionProperty = "none";
    indicator.style.opacity = "1";
    indicator.style.width = `${active.offsetWidth}px`;
    indicator.style.transform = `translateX(${active.offsetLeft}px)`;
    if (!animate) {
      void indicator.offsetWidth;
      indicator.style.transitionProperty = "";
    }
  }, []);

  const visibleKey = visibleValues.join(" ");

  React.useLayoutEffect(() => {
    position(hasPositioned.current && !isStatic);
    hasPositioned.current = true;
    // Focus follows keyboard-driven selection once the tab is in the strip.
    if (keyboardNav.current) {
      const active = rowRef.current?.querySelector<HTMLElement>(
        '[role="tab"][aria-selected="true"]',
      );
      if (active) {
        active.focus();
        keyboardNav.current = false;
      }
    }
  }, [position, value, visibleKey, overflow, isStatic]);

  React.useEffect(() => {
    const list = listRef.current;
    if (!list) return;
    const observer = new ResizeObserver(() => {
      compute();
      position(false);
    });
    observer.observe(list);
    return () => observer.disconnect();
  }, [compute, position]);

  /* ---------------------------- scroll mode bits -------------------------- */

  const updateFades = React.useCallback(() => {
    const list = listRef.current;
    if (!list) return;
    setFades({
      left: list.scrollLeft > 2,
      right: list.scrollLeft + list.clientWidth < list.scrollWidth - 2,
    });
  }, []);

  React.useEffect(() => {
    if (collapsing) return;
    updateFades();
    const list = listRef.current;
    if (!list) return;
    const observer = new ResizeObserver(updateFades);
    observer.observe(list);
    return () => observer.disconnect();
  }, [collapsing, updateFades, tabsKey]);

  // Keep the active tab in view when scrolling mode is on.
  React.useEffect(() => {
    if (collapsing) return;
    const list = listRef.current;
    const active = rowRef.current?.querySelector<HTMLElement>(
      '[role="tab"][aria-selected="true"]',
    );
    if (!list || !active) return;
    const left = active.offsetLeft - 12;
    const right = active.offsetLeft + active.offsetWidth + 12;
    const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
    const behavior: ScrollBehavior = reduced ? "auto" : "smooth";
    if (left < list.scrollLeft) list.scrollTo({ left, behavior });
    else if (right > list.scrollLeft + list.clientWidth)
      list.scrollTo({ left: right - list.clientWidth, behavior });
  }, [collapsing, value]);

  const drag = React.useRef({ startX: 0, startScroll: 0, moved: false });
  const onPointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
    if (collapsing || event.pointerType !== "mouse" || event.button !== 0) return;
    const list = listRef.current;
    if (!list || list.scrollWidth <= list.clientWidth) return;
    drag.current = {
      startX: event.clientX,
      startScroll: list.scrollLeft,
      moved: false,
    };
    list.setPointerCapture(event.pointerId);
  };
  const onPointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
    const list = listRef.current;
    if (!list || !list.hasPointerCapture?.(event.pointerId)) return;
    const dx = event.clientX - drag.current.startX;
    if (Math.abs(dx) > 4) drag.current.moved = true;
    list.scrollLeft = drag.current.startScroll - dx;
  };
  const onClickCapture = (event: React.MouseEvent<HTMLDivElement>) => {
    if (drag.current.moved) {
      event.preventDefault();
      event.stopPropagation();
      drag.current.moved = false;
    }
  };

  /* -------------------------------- keyboard ------------------------------ */

  const onKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
    if (!["ArrowRight", "ArrowLeft", "Home", "End"].includes(event.key)) return;
    const all = tabs.map((tab) => tab.value);
    const current = all.indexOf(value);
    let next = current;
    if (event.key === "ArrowRight") next = (current + 1) % all.length;
    if (event.key === "ArrowLeft") next = (current - 1 + all.length) % all.length;
    if (event.key === "Home") next = 0;
    if (event.key === "End") next = all.length - 1;
    if (next !== current) {
      keyboardNav.current = true;
      setValue(all[next]);
    }
    event.preventDefault();
  };

  /* -------------------------------- render -------------------------------- */

  const visibleSet = new Set(visibleValues);
  const shown = collapsing
    ? tabs.filter((tab) => visibleSet.has(tab.value))
    : tabs;
  const hidden = collapsing
    ? tabs.filter((tab) => !visibleSet.has(tab.value))
    : [];

  const renderTab = (tab: OverflowTab) => (
    <button
      key={tab.value}
      type="button"
      role="tab"
      aria-selected={tab.value === value}
      tabIndex={tab.value === value ? 0 : -1}
      onClick={() => setValue(tab.value)}
      className={tabClasses(tab.value === value)}
    >
      {tab.icon}
      {tab.label}
    </button>
  );

  return (
    <div
      data-slot="overflow-tabs"
      className={cn("relative w-full min-w-0", className)}
      {...props}
    >
      <div
        ref={listRef}
        role="tablist"
        onKeyDown={onKeyDown}
        onPointerDown={onPointerDown}
        onPointerMove={onPointerMove}
        onClickCapture={onClickCapture}
        onScroll={collapsing ? undefined : updateFades}
        className={cn(
          "relative flex w-full items-center rounded-lg bg-muted p-1",
          collapsing
            ? "overflow-hidden"
            : "cursor-grab overflow-x-auto [scrollbar-width:none] active:cursor-grabbing [&::-webkit-scrollbar]:hidden",
        )}
      >
        <div
          ref={rowRef}
          className="relative flex min-w-max items-center gap-0.5"
        >
          <span
            ref={indicatorRef}
            aria-hidden
            className="pointer-events-none absolute inset-y-0 left-0 rounded-md bg-background opacity-0 shadow-border [transition-property:transform,width] duration-200 ease-out motion-reduce:transition-none dark:bg-foreground/10"
          />
          {shown.map(renderTab)}
        </div>

        {hidden.length > 0 && (
          <DropdownMenu>
            <DropdownMenuTrigger
              aria-label={`${hidden.length} more tabs`}
              className={cn(
                tabClasses(false),
                "z-10 ml-0.5 tabular-nums data-[state=open]:text-foreground",
              )}
            >
              +{hidden.length}
            </DropdownMenuTrigger>
            <DropdownMenuContent align="end" className="min-w-44">
              {hidden.map((tab) => (
                <DropdownMenuItem
                  key={tab.value}
                  onSelect={() => setValue(tab.value)}
                >
                  {tab.icon}
                  {tab.label}
                </DropdownMenuItem>
              ))}
            </DropdownMenuContent>
          </DropdownMenu>
        )}

        {/* Invisible measuring clone — every tab plus the widest "+N". */}
        {collapsing && (
          <div
            ref={measureRef}
            aria-hidden
            className="pointer-events-none invisible absolute top-0 left-0 flex h-0 items-center gap-0.5 overflow-hidden"
          >
            {tabs.map((tab) => (
              <span
                key={tab.value}
                data-measure-tab={tab.value}
                className={tabClasses(false)}
              >
                {tab.icon}
                {tab.label}
              </span>
            ))}
            <span data-measure-plus className={cn(tabClasses(false), "tabular-nums")}>
              +{tabs.length}
            </span>
          </div>
        )}
      </div>

      {/* Edge fades for scroll mode. */}
      {!collapsing && (
        <>
          <div
            aria-hidden
            className={cn(
              "pointer-events-none absolute inset-y-0 left-0 w-8 rounded-l-lg bg-gradient-to-r from-muted to-transparent transition-opacity duration-150 ease-out",
              fades.left ? "opacity-100" : "opacity-0",
            )}
          />
          <div
            aria-hidden
            className={cn(
              "pointer-events-none absolute inset-y-0 right-0 w-8 rounded-r-lg bg-gradient-to-l from-muted to-transparent transition-opacity duration-150 ease-out",
              fades.right ? "opacity-100" : "opacity-0",
            )}
          />
        </>
      )}
    </div>
  );
}