"use client"; import { motion, MotionConfig, useReducedMotion, type Transition } from "motion/react"; import { createContext, useContext, useId, 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 — borrowed from dimi.me/lab/animated-tabs. 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 = (v: string) => { if (!controlled) setInternal(v); onValueChange?.(v); }; 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 ( ); } // Pill + Segment use the same trick: a max-contrast pill slides via layoutId, // text uses `mix-blend-exclusion` so it inverts dynamically against the moving bg. 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(); if (current !== value) return null; return ( {children} ); }