"use client";
import * as React from "react";
import { AnimatePresence, motion } from "motion/react";
export interface ScrollableTabItem {
/** Unique value identifying the tab. */
value: string;
/** Visible tab label. */
label: React.ReactNode;
/** Plain-text name used for the close button and announcements when `label` is not a string. */
textLabel?: string;
/** Optional leading icon. */
icon?: React.ReactNode;
/** Optional panel content. When any item has content, tab panels are rendered. */
content?: React.ReactNode;
/** Overrides the component-level `closable` for this tab. */
closable?: boolean;
disabled?: boolean;
}
type Spring = { stiffness: number; damping: number; mass: number };
export interface ScrollableTabsProps
extends Omit<
React.HTMLAttributes<HTMLDivElement>,
"defaultValue" | "onChange" | "children" | "onDrag" | "onDragStart" | "onDragEnd" | "onAnimationStart" | "onAnimationEnd"
> {
items: ScrollableTabItem[];
/** Selected value (controlled). */
value?: string;
/** Initially selected value (uncontrolled). Defaults to the first enabled tab. */
defaultValue?: string;
/** Called when the selection changes. */
onValueChange?: (value: string) => void;
/** Shows a close glyph on every tab and enables the Delete key. Remove the item in `onClose`. */
closable?: boolean;
/** Called with the value of a tab the user closed. If it was selected, a neighbour is selected first. */
onClose?: (value: string) => void;
/** Indicator style. */
variant?: "underline" | "pill";
/** "automatic" selects on arrow-key focus; "manual" waits for Enter / Space. */
activation?: "automatic" | "manual";
/** Show scroll buttons at the edges while the strip overflows. */
showScrollButtons?: boolean;
/** Fade the strip out at the edges that can scroll. */
fadeEdges?: boolean;
/** Turn vertical mouse-wheel movement into horizontal scrolling while the strip can scroll. */
wheelScroll?: boolean;
/** Spring for the sliding indicator. */
spring?: Spring;
/** Indicator and focus colour. */
accent?: string;
/** Accessible name of the tab list. */
label?: string;
}
function subscribeReducedMotion(callback: () => void) {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
mq.addEventListener("change", callback);
return () => mq.removeEventListener("change", callback);
}
/** SSR-safe: hydrates as "no preference", then follows the media query. */
function usePrefersReducedMotion() {
return React.useSyncExternalStore(
subscribeReducedMotion,
() => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
() => false,
);
}
const FADE = 48; // px faded at a scrollable edge (also the room kept for the scroll buttons)
const textOf = (item: ScrollableTabItem) =>
item.textLabel ?? (typeof item.label === "string" || typeof item.label === "number" ? String(item.label) : item.value);
/**
* A tab strip for many tabs. It scrolls horizontally (touch, trackpad, wheel or edge buttons), fades the
* edges that can scroll, keeps the selected tab in view by setting scrollLeft, and slides a shared-layout
* indicator between tabs. Implements the WAI-ARIA tabs pattern with roving tabindex; tabs can be closable.
*/
export function ScrollableTabs({
items,
value,
defaultValue,
onValueChange,
closable = false,
onClose,
variant = "underline",
activation = "automatic",
showScrollButtons = true,
fadeEdges = true,
wheelScroll = true,
spring = { stiffness: 420, damping: 36, mass: 0.8 },
accent = "#FF7448",
label = "Tabs",
className = "",
style,
...props
}: ScrollableTabsProps) {
const id = React.useId();
const reduceMotion = usePrefersReducedMotion();
const scrollerRef = React.useRef<HTMLDivElement>(null);
const listRef = React.useRef<HTMLDivElement>(null);
const tabRefs = React.useRef(new Map<string, HTMLButtonElement>());
const pendingFocusRef = React.useRef<string | null>(null);
const didMountRef = React.useRef(false);
const [edges, setEdges] = React.useState({ start: false, end: false });
const [announcement, setAnnouncement] = React.useState("");
const enabled = items.filter((item) => !item.disabled);
const [internal, setInternal] = React.useState(defaultValue ?? enabled[0]?.value);
const selected = value ?? internal;
// The tab reached with Tab: the selected one, or the first enabled tab as a fallback.
const focusable = enabled.some((item) => item.value === selected) ? selected : enabled[0]?.value;
const hasPanels = items.some((item) => item.content !== undefined);
const isClosable = (item: ScrollableTabItem) => !item.disabled && (item.closable ?? closable);
const indexOf = (v: string) => items.findIndex((item) => item.value === v);
const select = (next: string) => {
if (next === selected) return;
if (value === undefined) setInternal(next);
onValueChange?.(next);
};
/* ------------------------------ scroll state ------------------------------ */
const updateEdges = React.useCallback(() => {
const el = scrollerRef.current;
if (!el) return;
const max = el.scrollWidth - el.clientWidth;
const start = el.scrollLeft > 1;
const end = el.scrollLeft < max - 1;
setEdges((prev) => (prev.start === start && prev.end === end ? prev : { start, end }));
}, []);
// Track overflow on scroll and whenever the strip or its content changes size (fires once on observe).
React.useEffect(() => {
const el = scrollerRef.current;
const list = listRef.current;
if (!el || !list) return;
el.addEventListener("scroll", updateEdges, { passive: true });
const ro = new ResizeObserver(updateEdges);
ro.observe(el);
ro.observe(list);
return () => {
el.removeEventListener("scroll", updateEdges);
ro.disconnect();
};
}, [updateEdges]);
// Vertical wheel → horizontal scroll, but only while the strip can still move in that direction,
// so the page keeps scrolling normally once an end is reached.
React.useEffect(() => {
const el = scrollerRef.current;
if (!el || !wheelScroll) return;
const onWheel = (event: WheelEvent) => {
if (event.ctrlKey || Math.abs(event.deltaY) <= Math.abs(event.deltaX)) return;
const max = el.scrollWidth - el.clientWidth;
if (max <= 0) return;
const delta = event.deltaMode === 1 ? event.deltaY * 16 : event.deltaY;
if ((delta < 0 && el.scrollLeft <= 0) || (delta > 0 && el.scrollLeft >= max - 1)) return;
event.preventDefault();
el.scrollLeft += delta;
};
el.addEventListener("wheel", onWheel, { passive: false });
return () => el.removeEventListener("wheel", onWheel);
}, [wheelScroll]);
/** Scrolls the strip (never the page) so the tab sits inside the unfaded area. */
const revealTab = React.useCallback(
(tab: HTMLElement, instant: boolean) => {
const el = scrollerRef.current;
if (!el) return;
const strip = el.getBoundingClientRect();
const rect = tab.getBoundingClientRect();
const pad = Math.min(FADE, strip.width / 4);
let delta = 0;
if (rect.left < strip.left + pad) delta = rect.left - (strip.left + pad);
else if (rect.right > strip.right - pad) delta = rect.right - (strip.right - pad);
if (Math.abs(delta) < 1) return;
el.scrollTo({ left: el.scrollLeft + delta, behavior: instant || reduceMotion ? "auto" : "smooth" });
},
[reduceMotion],
);
// Keep the selected tab in view (instantly on mount, smoothly afterwards).
React.useEffect(() => {
const tab = selected ? tabRefs.current.get(selected) : undefined;
if (tab) revealTab(tab, !didMountRef.current);
didMountRef.current = true;
}, [selected, revealTab]);
// After a close, move focus to the neighbour once the parent has removed the tab.
React.useEffect(() => {
const pending = pendingFocusRef.current;
if (!pending) return;
pendingFocusRef.current = null;
tabRefs.current.get(pending)?.focus({ preventScroll: true });
}, [items]);
const scrollByPage = (direction: 1 | -1) => {
const el = scrollerRef.current;
if (!el) return;
el.scrollBy({ left: direction * el.clientWidth * 0.75, behavior: reduceMotion ? "auto" : "smooth" });
};
/* -------------------------------- closing -------------------------------- */
const close = (item: ScrollableTabItem) => {
if (!isClosable(item)) return;
const position = enabled.findIndex((entry) => entry.value === item.value);
const neighbour = enabled[position + 1] ?? enabled[position - 1];
const wasSelected = item.value === selected;
if (wasSelected && neighbour) select(neighbour.value);
const focusedInside = listRef.current?.contains(document.activeElement) ?? false;
if (neighbour && focusedInside) pendingFocusRef.current = neighbour.value;
setAnnouncement(`Closed ${textOf(item)}.${wasSelected && neighbour ? ` ${textOf(neighbour)} selected.` : ""}`);
onClose?.(item.value);
};
/* -------------------------------- keyboard -------------------------------- */
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
const current = (event.target as HTMLElement).closest<HTMLElement>("[role=tab]")?.dataset.value;
if (!current || !enabled.length) return;
const item = items[indexOf(current)];
if ((event.key === "Delete" || event.key === "Backspace") && item && isClosable(item)) {
event.preventDefault();
close(item);
return;
}
if (activation === "manual" && (event.key === "Enter" || event.key === " ")) {
event.preventDefault();
select(current);
return;
}
if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) return;
event.preventDefault();
const index = enabled.findIndex((entry) => entry.value === current);
const last = enabled.length - 1;
const nextIndex =
event.key === "Home"
? 0
: event.key === "End"
? last
: event.key === "ArrowRight"
? (index + 1) % enabled.length
: (index - 1 + enabled.length) % enabled.length;
const next = enabled[nextIndex];
const tab = tabRefs.current.get(next.value);
tab?.focus({ preventScroll: true });
if (tab) revealTab(tab, false);
if (activation === "automatic") select(next.value);
};
/* --------------------------------- render --------------------------------- */
const transition = reduceMotion ? { duration: 0 } : { type: "spring" as const, ...spring };
const fadeStart = fadeEdges && edges.start ? FADE : 0;
const fadeEnd = fadeEdges && edges.end ? FADE : 0;
const mask =
fadeStart || fadeEnd
? `linear-gradient(to right, transparent 0, #000 ${fadeStart}px, #000 calc(100% - ${fadeEnd}px), transparent 100%)`
: undefined;
const underline = variant === "underline";
const hintId = `${id}-close-hint`;
return (
<div className={`w-full min-w-0 ${className}`} style={{ ["--st-accent" as string]: accent, ...style }} {...props}>
<div className={`relative ${underline ? "" : "rounded-xl border border-zinc-900/[0.07] bg-zinc-100 p-1 dark:border-white/[0.06] dark:bg-zinc-900"}`}>
<motion.div
ref={scrollerRef}
layoutScroll
className="overflow-x-auto overflow-y-hidden overscroll-x-contain [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
style={{ maskImage: mask, WebkitMaskImage: mask }}
>
<div
ref={listRef}
role="tablist"
aria-label={label}
aria-orientation="horizontal"
onKeyDown={handleKeyDown}
className={`relative flex w-max min-w-full items-stretch ${
underline ? "gap-1 border-b border-zinc-900/10 dark:border-white/10" : "gap-1"
}`}
>
<AnimatePresence initial={false} mode="popLayout">
{items.map((item) => {
const isSelected = item.value === selected;
const index = indexOf(item.value);
const canClose = isClosable(item);
return (
<motion.button
key={item.value}
ref={(node: HTMLButtonElement | null) => {
if (node) tabRefs.current.set(item.value, node);
else tabRefs.current.delete(item.value);
}}
layout={reduceMotion ? false : "position"}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0, transition: { duration: reduceMotion ? 0 : 0.15 } }}
transition={transition}
type="button"
role="tab"
id={`${id}-tab-${index}`}
data-value={item.value}
aria-selected={isSelected}
aria-controls={hasPanels ? `${id}-panel-${index}` : undefined}
aria-describedby={canClose ? hintId : undefined}
tabIndex={item.value === focusable ? 0 : -1}
disabled={item.disabled}
onClick={() => select(item.value)}
onFocus={(event) => revealTab(event.currentTarget, false)}
onAuxClick={(event) => {
// Middle-click closes, like browser tabs.
if (event.button === 1 && canClose) {
event.preventDefault();
close(item);
}
}}
className={`group/tab relative inline-flex shrink-0 select-none items-center gap-2 whitespace-nowrap text-[13px] font-medium tracking-tight outline-hidden transition-colors duration-200 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-solid focus-visible:outline-[var(--st-accent)] disabled:cursor-not-allowed disabled:opacity-40 ${
underline
? "h-10 rounded-md px-3 text-zinc-500 hover:text-zinc-900 aria-selected:text-zinc-950 dark:text-zinc-400 dark:hover:text-zinc-100 dark:aria-selected:text-white"
: "h-8 rounded-lg px-3 text-zinc-500 hover:text-zinc-900 aria-selected:text-zinc-950 dark:text-zinc-400 dark:hover:text-zinc-100 dark:aria-selected:text-white"
} ${canClose ? "pr-2" : ""}`}
>
{isSelected && (
<motion.span
aria-hidden="true"
// useId keeps the layoutId unique, so several strips on one page never share an indicator.
layoutId={`${id}-indicator`}
transition={transition}
className={
underline
? "absolute inset-x-2 -bottom-px h-[2px] rounded-full bg-[var(--st-accent)]"
: "absolute inset-0 rounded-lg bg-white shadow-[0_1px_2px_rgba(0,0,0,0.08),0_0_0_1px_rgba(0,0,0,0.05)] dark:bg-zinc-800 dark:shadow-[0_1px_2px_rgba(0,0,0,0.5),0_0_0_1px_rgba(255,255,255,0.06)]"
}
/>
)}
{item.icon && (
<span aria-hidden="true" className="relative inline-flex [&_svg]:size-3.5">
{item.icon}
</span>
)}
<span className="relative">{item.label}</span>
{canClose && (
// Pointer target only: a nested button inside role="tab" is invalid, so keyboard users press Delete.
<span
aria-hidden="true"
title={`Close ${textOf(item)}`}
onClick={(event) => {
event.stopPropagation();
close(item);
}}
className="relative -my-1 inline-flex size-5 items-center justify-center rounded-md text-zinc-400 opacity-60 transition-[opacity,background-color,color] hover:bg-zinc-900/[0.07] hover:text-zinc-900 hover:opacity-100 group-hover/tab:opacity-100 group-aria-selected/tab:opacity-100 dark:text-zinc-500 dark:hover:bg-white/10 dark:hover:text-zinc-100"
>
<svg viewBox="0 0 12 12" className="size-2.5" fill="none">
<path d="M3 3l6 6M9 3l-6 6" stroke="currentColor" strokeWidth={1.5} strokeLinecap="round" />
</svg>
</span>
)}
</motion.button>
);
})}
</AnimatePresence>
</div>
</motion.div>
{showScrollButtons && (
<>
<ScrollButton direction={-1} visible={edges.start} onClick={() => scrollByPage(-1)} inset={!underline} />
<ScrollButton direction={1} visible={edges.end} onClick={() => scrollByPage(1)} inset={!underline} />
</>
)}
</div>
<span id={hintId} className="sr-only">
Press Delete to close this tab.
</span>
<div role="status" aria-live="polite" aria-atomic="true" className="sr-only">
{announcement}
</div>
{hasPanels &&
items.map((item, index) => {
const isSelected = item.value === selected;
return (
<div
key={item.value}
role="tabpanel"
id={`${id}-panel-${index}`}
aria-labelledby={`${id}-tab-${index}`}
hidden={!isSelected}
tabIndex={0}
className="mt-4 rounded-xl outline-hidden focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-[var(--st-accent)]"
>
{isSelected && (
<motion.div
initial={reduceMotion ? false : { opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.22, ease: [0.16, 1, 0.3, 1] }}
>
{item.content}
</motion.div>
)}
</div>
);
})}
</div>
);
}
function ScrollButton({
direction,
visible,
onClick,
inset,
}: {
direction: 1 | -1;
visible: boolean;
onClick: () => void;
inset: boolean;
}) {
return (
<button
type="button"
// Pointer convenience: keyboard users move with the arrow keys, which also scroll the strip.
tabIndex={-1}
aria-label={direction < 0 ? "Scroll tabs left" : "Scroll tabs right"}
aria-hidden={!visible}
onClick={onClick}
className={`absolute top-1/2 z-10 flex size-7 -translate-y-1/2 items-center justify-center rounded-full border border-zinc-900/10 bg-white text-zinc-600 shadow-[0_1px_3px_rgba(0,0,0,0.08)] transition-[opacity,scale,color] duration-200 hover:text-zinc-950 active:scale-95 dark:border-white/10 dark:bg-zinc-900 dark:text-zinc-300 dark:shadow-[0_1px_3px_rgba(0,0,0,0.5)] dark:hover:text-white ${
direction < 0 ? (inset ? "left-1.5" : "left-0") : inset ? "right-1.5" : "right-0"
} ${visible ? "opacity-100" : "pointer-events-none scale-90 opacity-0"}`}
>
<svg viewBox="0 0 16 16" className="size-3.5" fill="none" aria-hidden="true">
<path
d={direction < 0 ? "M10 3.5 5.5 8l4.5 4.5" : "M6 3.5 10.5 8 6 12.5"}
stroke="currentColor"
strokeWidth={1.6}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
);
}