"use client"; import { motion, MotionConfig, useReducedMotion, type Transition } from "motion/react"; import { createContext, useCallback, useContext, useId, 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; } // Weighty spring for the active-tab indicator: a touch of overshoot so it // settles with life instead of snapping. const transition: Transition = { type: "spring", stiffness: 170, damping: 24, 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 }: { children: ReactNode; className?: string }) { const { variant } = useTabs(); return (
{children}
); } export function TabsTrigger({ value, children, className, indicatorClassName, }: { value: string; children: ReactNode; className?: string; indicatorClassName?: string; }) { const { value: current, setValue, layoutId, variant } = useTabs(); const active = current === value; 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} ); }