"use client"; import { ChevronLeft, ChevronRight } from "lucide-react"; import { cancelFrame, frame, motion, MotionConfig, useReducedMotion, useSpring, type MotionValue, type Transition } from "motion/react"; import { createContext, useCallback, useContext, useId, useLayoutEffect, useRef, useMemo, useState, type ButtonHTMLAttributes, type HTMLAttributes, type KeyboardEvent, type ReactNode, } from "react"; import { EASE_OUT, SPRING_SWAP } from "@/lib/ease"; import { cn } from "@/lib/utils"; type Variant = "pill" | "underline" | "segment"; /** Whether trigger icons show on every tab or only on the selected one. */ type IconDisplay = "always" | "selected"; type Ctx = { value: string; setValue: (v: string) => void; layoutId: string; variant: Variant; icons: IconDisplay; /** The pill's box in the list, for tabs whose width changes as they're selected. */ track: Track; /** Whether the tracked pill has been measured; until then CSS places it. */ placed: boolean; setPlaced: (placed: boolean) => void; /** * The one tab Tab reaches: the selected tab, or the first enabled one when * nothing enabled is selected. `null` until the triggers have registered, * when every enabled tab stays reachable. */ tabStop: string | null; registerTab: (value: string, disabled: boolean) => void; unregisterTab: (value: string) => void; /** Values with a mounted panel, so triggers only point at panels that exist. */ panels: ReadonlySet; registerPanel: (value: string) => void; unregisterPanel: (value: string) => void; tabId: (value: string) => string; panelId: (value: string) => string; }; type Track = Record<"x" | "y" | "width" | "height", MotionValue>; const TabsCtx = createContext(null); function useTabs() { const ctx = useContext(TabsCtx); if (!ctx) throw new Error("Tabs.* must be used inside "); return ctx; } // Settle without overshoot: a scrollable tab list would turn even a small // overshoot into a transient scrollbar and layout shift. // Scale stiffness and damping together for a quicker glide with the same feel. const transition: Transition = { type: "spring", stiffness: 245, damping: 36, mass: 1.2, }; export function Tabs({ defaultValue, value, onValueChange, variant = "pill", icons = "always", children, className, }: { defaultValue?: string; value?: string; onValueChange?: (v: string) => void; variant?: Variant; /** * With "selected", a trigger's icon springs open only on the selected tab * and folds away when another is chosen. Defaults to "always". */ icons?: IconDisplay; children: ReactNode; className?: string; }) { const [internal, setInternal] = useState(defaultValue ?? ""); const layoutId = useId(); const reduce = useReducedMotion(); const controlled = value !== undefined; const current = controlled ? value : internal; const setValue = useCallback( (v: string) => { if (!controlled) setInternal(v); onValueChange?.(v); }, [controlled, onValueChange], ); const x = useSpring(0, transition); const y = useSpring(0, transition); const width = useSpring(0, transition); const height = useSpring(0, transition); const track = useMemo(() => ({ x, y, width, height }), [x, y, width, height]); const [placed, setPlaced] = useState(false); const [tabs, setTabs] = useState>(new Map()); const registerTab = useCallback((tab: string, disabled: boolean) => { setTabs((map) => (map.get(tab) === disabled ? map : new Map(map).set(tab, disabled))); }, []); const unregisterTab = useCallback((tab: string) => { setTabs((map) => { if (!map.has(tab)) return map; const next = new Map(map); next.delete(tab); return next; }); }, []); const [panels, setPanels] = useState>(new Set()); const registerPanel = useCallback((panel: string) => { setPanels((set) => (set.has(panel) ? set : new Set(set).add(panel))); }, []); const unregisterPanel = useCallback((panel: string) => { setPanels((set) => { if (!set.has(panel)) return set; const next = new Set(set); next.delete(panel); return next; }); }, []); // Resolved during render so the list has exactly one tab stop in the same // commit that changes the selection or the tabs. let tabStop: string | null = null; if (tabs.size > 0) { const enabled = Array.from(tabs).filter(([, disabled]) => !disabled); tabStop = enabled.some(([tab]) => tab === current) ? current : (enabled[0]?.[0] ?? ""); } const baseId = useId(); const tabId = useCallback((tab: string) => `${baseId}-tab-${tab.replace(/[^\w-]/g, "_")}`, [baseId]); const panelId = useCallback((tab: string) => `${baseId}-panel-${tab.replace(/[^\w-]/g, "_")}`, [baseId]); const contextValue = useMemo( () => ({ value: current, setValue, layoutId, variant, icons, track, placed, setPlaced, tabStop, registerTab, unregisterTab, panels, registerPanel, unregisterPanel, tabId, panelId, }), [current, icons, layoutId, setValue, variant, track, placed, tabStop, registerTab, unregisterTab, panels, registerPanel, unregisterPanel, tabId, panelId], ); return ( {/* layoutRoot: the indicator's layoutId measures in page coordinates, so inside fixed/scrolled containers it would replay scroll offsets as movement. The pill only ever travels within the list, so scoping projection to the Tabs wrapper is always correct. */} {children} ); } const listClasses: Record = { pill: "inline-flex items-center gap-1 rounded-full bg-card p-1", underline: "inline-flex items-center gap-1 border-b border-border", segment: "inline-flex items-center gap-0 rounded-lg bg-card p-0.5", }; export function TabsList({ children, className, wrapperClassName, onKeyDown, ...props }: { wrapperClassName?: string; } & HTMLAttributes) { const { variant, value, icons, track, placed, setPlaced } = useTabs(); const reduce = useReducedMotion(); const tracked = icons === "selected" && variant !== "underline"; const rootRef = useRef(null); const viewportRef = useRef(null); const listRef = useRef(null); const viewportId = useId(); const [edges, setEdges] = useState({ overflow: false, left: false, right: false }); const measure = useCallback(() => { const root = rootRef.current; const viewport = viewportRef.current; if (!root || !viewport) return; // Overlay controls do not reduce the viewport or change its scroll range. const overflow = viewport.scrollWidth > root.clientWidth + 1; const max = Math.max(0, viewport.scrollWidth - viewport.clientWidth); const rtl = getComputedStyle(viewport).direction === "rtl"; // Modern browsers expose negative scrollLeft in RTL. Clamp rubber-banding. const fromLeft = Math.max(0, Math.min(max, rtl ? max + viewport.scrollLeft : viewport.scrollLeft)); const next = { overflow, left: fromLeft > 1, right: fromLeft < max - 1 }; setEdges((previous) => previous.overflow === next.overflow && previous.left === next.left && previous.right === next.right ? previous : next); }, []); const reveal = useCallback((tab: HTMLElement | null) => { const viewport = viewportRef.current; if (!viewport || !tab) return; const frame = viewport.getBoundingClientRect(); const item = tab.getBoundingClientRect(); const max = Math.max(0, viewport.scrollWidth - viewport.clientWidth); const rtl = getComputedStyle(viewport).direction === "rtl"; const fromLeft = Math.max(0, Math.min(max, rtl ? max + viewport.scrollLeft : viewport.scrollLeft)); // Keep the selected/focused label clear of the arrows over the faded edges. const left = frame.left + (fromLeft > 1 ? 36 : 0); const right = frame.right - (fromLeft < max - 1 ? 36 : 0); const delta = item.left < left ? item.left - left : item.right > right ? item.right - right : 0; // Scroll only this viewport; scrollIntoView can also move the whole page. if (delta) viewport.scrollBy({ left: delta, behavior: reduce ? "instant" : "smooth" }); }, [reduce]); useLayoutEffect(() => { const root = rootRef.current; const viewport = viewportRef.current; const list = listRef.current; if (!root || !viewport || !list) return; const update = () => { measure(); reveal(list.querySelector('[role="tab"][aria-selected="true"]')); }; const observer = new ResizeObserver(update); observer.observe(root); observer.observe(viewport); observer.observe(list); viewport.addEventListener("scroll", measure, { passive: true }); update(); return () => { observer.disconnect(); viewport.removeEventListener("scroll", measure); }; }, [measure, reveal]); useLayoutEffect(() => { // Children may change without a resize; controlled selection must also reveal. void children; void value; void edges.overflow; measure(); reveal(listRef.current?.querySelector('[role="tab"][aria-selected="true"]') ?? null); }, [children, value, edges.overflow, measure, reveal]); useLayoutEffect(() => { // With icons="selected" the chosen tab grows as its icon opens and the // last one shrinks. A layout animation measures the tabs mid-change, so // the pill would squeeze and catch up. Instead the pill springs straight // to where the selected tab will settle, with every icon open or closed. if (!tracked) return; const list = listRef.current; if (!list) return; void children; const place = () => { const tabs = Array.from(list.querySelectorAll('[role="tab"]')); const target = tabs.find((tab) => tab.dataset.tabsValue === value)?.parentElement; if (!target) return; // Measure the settled layout: open the selected icon and fold the rest // for one synchronous read, then hand the styles back to Motion before // anything paints. Exact in any layout, from flex rows to grids. const icons = Array.from(list.querySelectorAll('[role="tab"] > [data-tabs-icon]')); const saved = icons.map((icon) => [icon.style.width, icon.style.marginRight]); for (const icon of icons) { const open = icon.parentElement?.parentElement === target; icon.style.width = open ? "auto" : "0px"; icon.style.marginRight = open ? `${ICON_GAP}px` : "0px"; } const box = { x: target.offsetLeft, y: target.offsetTop, // Fractional: a rounded width leaves the pill a hair off its tab. width: target.getBoundingClientRect().width, height: target.offsetHeight, }; icons.forEach((icon, index) => { [icon.style.width, icon.style.marginRight] = saved[index]; }); // A first measurement, or one after the list was hidden, lands in place. const instant = reduce || track.width.get() === 0; for (const key of ["x", "y", "width", "height"] as const) { if (instant) track[key].jump(box[key]); else track[key].set(box[key]); } // Hand over from the CSS pill once there's a real box, before paint. if (box.width > 0) setPlaced(true); }; place(); const observer = new ResizeObserver(place); for (const tab of list.querySelectorAll('[role="tab"]')) if (tab.parentElement) observer.observe(tab.parentElement); return () => observer.disconnect(); }, [tracked, value, children, reduce, track, setPlaced]); useLayoutEffect(() => { if (variant === "underline") return; const list = listRef.current; if (!list) return; void children; // `placed` swaps the indicator element, so measure the new one. void placed; const labels = Array.from(list.querySelectorAll("[data-tabs-label]")); const indicator = list.querySelector("[data-tabs-indicator]"); const target = list.querySelector('[role="tab"][aria-selected="true"]'); if (!indicator || !target || target.dataset.tabsValue !== value) { for (const label of labels) label.style.clipPath = "inset(0 100% 0 0)"; return; } let frames = 0; let stillFrames = 0; let previous: { left: number; right: number } | undefined; const syncClips = () => { const pill = (reduce ? target : indicator).getBoundingClientRect(); // Read ALL geometry before writing ANY masks. A loop per tab interleaved // reads and writes, forcing the browser to flush styles repeatedly. const clips = labels.map((label) => { const bounds = label.getBoundingClientRect(); const left = Math.max(0, Math.min(bounds.width, pill.left - bounds.left)); const right = Math.max(0, Math.min(bounds.width, bounds.right - pill.right)); return left + right >= bounds.width ? "inset(0 100% 0 0)" : `inset(0 ${right}px 0 ${left}px)`; }); labels.forEach((label, index) => { if (label.style.clipPath !== clips[index]) label.style.clipPath = clips[index]; }); frames += 1; stillFrames = previous && Math.abs(pill.left - previous.left) < 0.01 && Math.abs(pill.right - previous.right) < 0.01 ? stillFrames + 1 : 0; previous = { left: pill.left, right: pill.right }; if (reduce || (frames > 2 && stillFrames >= 2)) cancelFrame(syncClips); }; // One shared pass after Motion paints the projected pill keeps every label // in sync, including labels crossed during a long or interrupted glide. frame.postRender(syncClips, true); // A list measured while hidden (display: none, a closed panel, a // breakpoint) gets every mask closed. Sync again once it has a size, or // the selected label stays invisible until the value changes. const resync = () => { frames = 0; stillFrames = 0; previous = undefined; cancelFrame(syncClips); frame.postRender(syncClips, true); }; const observer = new ResizeObserver(resync); observer.observe(list); return () => { observer.disconnect(); cancelFrame(syncClips); }; }, [value, children, variant, reduce, placed]); // Arrow keys move along the list and select as focus arrives, wrapping at // the ends; Home and End jump to the first and last enabled tab. const onListKeyDown = (event: KeyboardEvent) => { onKeyDown?.(event); if (event.defaultPrevented) return; const list = listRef.current; if (!list || !(event.target instanceof HTMLElement)) return; const tabs = Array.from(list.querySelectorAll('[role="tab"]')).filter( (tab) => !tab.disabled && tab.closest('[role="tablist"]') === list, ); const at = tabs.indexOf(event.target as HTMLButtonElement); if (at < 0) return; const rtl = getComputedStyle(list).direction === "rtl"; const forward = rtl ? "ArrowLeft" : "ArrowRight"; const back = rtl ? "ArrowRight" : "ArrowLeft"; const next = event.key === forward ? tabs[(at + 1) % tabs.length] : event.key === back ? tabs[(at - 1 + tabs.length) % tabs.length] : event.key === "Home" ? tabs[0] : event.key === "End" ? tabs[tabs.length - 1] : null; if (!next) return; event.preventDefault(); next.focus(); next.click(); }; const scroll = (direction: number) => { const viewport = viewportRef.current; if (viewport) viewport.scrollBy({ left: direction * viewport.clientWidth * 0.8, behavior: reduce ? "instant" : "smooth" }); }; const controlClass = "absolute inset-y-0 z-20 inline-flex w-9 items-center justify-center text-foreground transition-opacity hover:opacity-70 focus-visible:outline-2 focus-visible:-outline-offset-4 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-0"; const surfaceClass = variant === "pill" ? "rounded-full bg-card" : variant === "segment" ? "rounded-lg bg-card" : ""; return (
{edges.overflow && ( )} { if (event.target instanceof HTMLElement && event.target.getAttribute("role") === "tab") reveal(event.target); }} >
{children}
{edges.overflow && edges.left && (
); } /** Space between a selected-only icon and its label, in pixels. */ const ICON_GAP = 6; /** * A trigger's icon before its label. Shown on every tab, or with * `icons="selected"` springing open on the selected tab only. */ function TriggerIcon({ icon, active }: { icon: ReactNode; active: boolean }) { const { icons } = useTabs(); const reduce = useReducedMotion(); if (icons === "always") { return ( ); } return ( ); } export function TabsTrigger({ value, icon, children, className, indicatorClassName, onClick, ...props }: { value: string; /** An icon before the label, such as a Lucide icon element. */ icon?: ReactNode; indicatorClassName?: string; } & Omit, "value">) { const { value: current, setValue, layoutId, variant, icons, track, placed, tabStop, registerTab, unregisterTab, panels, tabId, panelId, } = useTabs(); const active = current === value; const disabled = Boolean(props.disabled); useLayoutEffect(() => { registerTab(value, disabled); }, [registerTab, value, disabled]); useLayoutEffect(() => () => unregisterTab(value), [unregisterTab, value]); const a11y = { id: tabId(value), "aria-controls": panels.has(value) ? panelId(value) : undefined, tabIndex: !disabled && (tabStop === null || tabStop === value) ? 0 : -1, }; const content = icon ? ( <> {children} ) : ( children ); // React owns the initial mask only; TabsList synchronizes subsequent masks. const [initialClip] = useState(() => active ? "inset(0)" : "inset(0 100% 0 0)"); if (variant === "underline") { return ( ); } const radius = variant === "pill" ? "rounded-full" : "rounded-md"; // Tracked pills are placed in the list, so this wrapper must not be their // containing block. Server HTML can't measure, so until the first // measurement the pill fills the selected tab with CSS, like the others. const tracked = icons === "selected" && placed; return (
{/* Distinct keys: reused across the swap, the CSS pill's leftover `transform: none` would hide the tracked pill's offset until the next value change. */} {active && tracked ? ( ) : active ? ( ) : null}
); } export function TabsContent({ value, children, className }: { value: string; children: ReactNode; className?: string }) { const { value: current, registerPanel, unregisterPanel, tabId, panelId } = useTabs(); const reduce = useReducedMotion(); const active = current === value; useLayoutEffect(() => { registerPanel(value); return () => unregisterPanel(value); }, [registerPanel, unregisterPanel, value]); const a11y = { id: panelId(value), role: "tabpanel", "aria-labelledby": tabId(value), } as const; // Inactive panels stay mounted but hidden, so their content (e.g. source // code) is present in the server-rendered HTML for crawlers and assistive // tech, instead of being dropped from the DOM. if (!active) { return ( ); } return ( {children} ); }