Tab strip that measures itself and folds overflowing tabs into a +N menu — active pill slides between tabs, with a drag-scroll fallback mode.
npx shadcn@latest add @paragon/overflow-tabsAlso installs: dropdown-menu
"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>
);
}