1"use client";2
3import React, { useId, useRef, useState } from "react";4import { motion, AnimatePresence, useReducedMotion } from "motion/react";5import { cn } from "@/lib/utils";6
7export type TabItem = {8 id: string | number;9 label: string;10 icon?: React.ReactNode;11 content: React.ReactNode;12};13
14export type AnimatedTabsProps = {15 tabs: TabItem[];16 defaultActiveId?: string | number;17 layout?: "horizontal" | "grid";18 className?: string;19};20
21const PILL_SPRING = { type: "spring" as const, bounce: 0, duration: 0.28 };22const PANEL = { duration: 0.22, ease: [0.23, 1, 0.32, 1] as const };23
24export function AnimatedTabs({25 tabs,26 defaultActiveId,27 layout = "grid",28 className = "",29}: AnimatedTabsProps) {30 const reduce = useReducedMotion();31 const layoutId = useId();32 const [activeId, setActiveId] = useState(defaultActiveId ?? tabs[0]?.id);33 const prevId = useRef(activeId);34 const direction =35 tabs.findIndex((t) => t.id === activeId) >=36 tabs.findIndex((t) => t.id === prevId.current)37 ? 138 : -1;39
40 const setTab = (id: string | number) => {41 prevId.current = activeId;42 setActiveId(id);43 };44
45 const activeTab = tabs.find((t) => t.id === activeId);46
47 return (48 <div49 className={cn(50 "flex flex-col gap-6 rounded-lg border border-(--color-rule)",51 "bg-(--color-paper-2) p-4",52 className,53 )}54 >55 <div56 className={cn(57 "w-fit rounded-md border border-(--color-rule) bg-(--color-paper) p-1",58 layout === "grid"59 ? "grid grid-cols-2 gap-1 sm:grid-cols-4"60 : "flex flex-wrap gap-1",61 )}62 >63 {tabs.map((tab) => {64 const isActive = tab.id === activeId;65 return (66 <motion.button67 key={tab.id}68 type="button"69 onClick={() => setTab(tab.id)}70 className={cn(71 "relative min-h-10 rounded-[calc(var(--radius-md)-2px)] px-3 py-2 text-sm outline-none",72 "transition-colors duration-100 ease-[cubic-bezier(0.23,1,0.32,1)]",73 "focus-visible:ring-2 focus-visible:ring-(--color-focus)",74 isActive75 ? "font-medium text-(--color-ink)"76 : "text-(--color-ink-muted) hover:text-(--color-ink-2)",77 )}78 whileTap={reduce ? undefined : { scale: 0.96 }}79 aria-pressed={isActive}80 >81 {isActive && (82 <motion.span83 layoutId={`menu-pill-${layoutId}`}84 className={cn(85 "absolute inset-0 z-0 rounded-[calc(var(--radius-md)-2px)]",86 "border border-(--color-rule) bg-(--color-paper-2)",87 "shadow-[0_1px_2px_rgba(0,0,0,0.04)]",88 )}89 transition={reduce ? { duration: 0.12 } : PILL_SPRING}90 />91 )}92 <span className="relative z-1 flex items-center justify-center gap-1.5">93 {tab.icon && (94 <span className="flex size-4 items-center justify-center [&_svg]:size-full [&_svg]:stroke-[1.5]">95 {tab.icon}96 </span>97 )}98 {tab.label}99 </span>100 </motion.button>101 );102 })}103 </div>104
105 <div106 className={cn(107 "w-full max-w-md rounded-md border border-(--color-rule)",108 "bg-(--color-paper) p-5",109 )}110 >111 <AnimatePresence mode="wait" initial={false} custom={direction}>112 <motion.div113 key={String(activeId)}114 custom={direction}115 initial={116 reduce117 ? { opacity: 0 }118 : { opacity: 0, x: direction * 16, filter: "blur(4px)" }119 }120 animate={{ opacity: 1, x: 0, filter: "blur(0px)" }}121 exit={122 reduce123 ? { opacity: 0 }124 : {125 opacity: 0,126 x: direction * -12,127 filter: "blur(4px)",128 transition: { duration: 0.16 },129 }130 }131 transition={reduce ? { duration: 0.12 } : PANEL}132 className="space-y-2"133 >134 <h3 className="text-balance text-lg font-semibold text-(--color-ink)">135 {activeTab?.label}136 </h3>137 <div className="text-sm leading-relaxed text-(--color-ink-2)">138 {activeTab?.content}139 </div>140 </motion.div>141 </AnimatePresence>142 </div>143 </div>144 );145}