"use client"; import { ChevronLeft, ChevronRight } from "lucide-react"; import { cancelFrame, frame, motion, MotionConfig, useReducedMotion, type Transition } from "motion/react"; import type { ButtonHTMLAttributes, HTMLAttributes } from "react"; import { createContext, useCallback, useContext, useId, useLayoutEffect, useRef, useMemo, useState, type ReactNode, } from "react"; import { EASE_OUT } from "@/lib/ease"; import { cn } from "@/lib/utils"; type Variant = "pill" | "underline" | "segment"; type Ctx = { value: string; setValue: (v: string) => void; layoutId: string; variant: Variant; }; 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", children, className, }: { defaultValue?: string; value?: string; onValueChange?: (v: string) => void; variant?: Variant; 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 contextValue = useMemo( () => ({ value: current, setValue, layoutId, variant }), [current, layoutId, setValue, variant], ); 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, ...props }: { wrapperClassName?: string; } & HTMLAttributes) { const { variant, value } = useTabs(); const reduce = useReducedMotion(); 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(() => { if (variant === "underline") return; const list = listRef.current; if (!list) return; void children; 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); return () => cancelFrame(syncClips); }, [value, children, variant, reduce]); 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 && (
); } export function TabsTrigger({ value, children, className, indicatorClassName, onClick, ...props }: { value: string; indicatorClassName?: string; } & Omit, "value">) { const { value: current, setValue, layoutId, variant } = useTabs(); const active = current === value; // 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"; return (
{active ? ( ) : null}
); } export function TabsContent({ value, children, className }: { value: string; children: ReactNode; className?: string }) { const { value: current } = useTabs(); const reduce = useReducedMotion(); const active = current === value; // 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} ); }