1"use client";2
3import React, { useEffect, useId, useRef, useState } from "react";4import type { KeyboardEvent } from "react";5import {6 AnimatePresence,7 LayoutGroup,8 motion,9 useReducedMotion,10} from "motion/react";11import { cn } from "@/lib/utils";12
13export type TabItem = {14 id: string | number;15 label: string;16 icon?: React.ReactNode;17 content: React.ReactNode;18};19
20export type AnimatedTabsProps = {21 tabs: TabItem[];22 defaultActiveId?: string | number;23 onChange?: (id: string | number) => void;24 layout?: "horizontal" | "grid";25 26 showTitle?: boolean;27 className?: string;28};29
30const PILL_SPRING = { type: "spring" as const, bounce: 0.15, duration: 0.42 };31const HEIGHT_SPRING = { type: "spring" as const, bounce: 0, duration: 0.38 };32const EASE_OUT = [0.23, 1, 0.32, 1] as const;33
34export function AnimatedTabs({35 tabs,36 defaultActiveId,37 onChange,38 layout = "horizontal",39 showTitle = true,40 className = "",41}: AnimatedTabsProps) {42 const reduce = useReducedMotion();43 const uid = useId();44 const [activeId, setActiveId] = useState(defaultActiveId ?? tabs[0]?.id);45 const [direction, setDirection] = useState(1);46 const [hovered, setHovered] = useState<string | number | null>(null);47 const tabRefs = useRef(new Map<string | number, HTMLButtonElement>());48
49 50 const innerRef = useRef<HTMLDivElement>(null);51 const [height, setHeight] = useState<number | "auto">("auto");52 useEffect(() => {53 const el = innerRef.current;54 if (!el) return;55 const ro = new ResizeObserver(([entry]) => setHeight(entry.contentRect.height));56 ro.observe(el);57 return () => ro.disconnect();58 }, []);59
60 const activeIndex = Math.max(0, tabs.findIndex((t) => t.id === activeId));61 const activeTab = tabs[activeIndex];62
63 const select = (id: string | number) => {64 const next = tabs.findIndex((t) => t.id === id);65 if (next < 0 || id === activeTab?.id) return;66 setDirection(next > activeIndex ? 1 : -1);67 setActiveId(id);68 onChange?.(id);69 };70
71 const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {72 const cols = layout === "grid" ? 3 : 1;73 let next = activeIndex;74 if (event.key === "ArrowRight") next = (activeIndex + 1) % tabs.length;75 else if (event.key === "ArrowLeft") next = (activeIndex - 1 + tabs.length) % tabs.length;76 else if (layout === "grid" && event.key === "ArrowDown") next = Math.min(tabs.length - 1, activeIndex + cols);77 else if (layout === "grid" && event.key === "ArrowUp") next = Math.max(0, activeIndex - cols);78 else if (event.key === "Home") next = 0;79 else if (event.key === "End") next = tabs.length - 1;80 else return;81 event.preventDefault();82 const tab = tabs[next];83 if (!tab) return;84 select(tab.id);85 tabRefs.current.get(tab.id)?.focus();86 };87
88 if (!activeTab) return null;89
90 const radius = layout === "grid" ? "rounded-[11px]" : "rounded-full";91 const tabId = (id: string | number) => `${uid}-tab-${id}`;92 const panelId = `${uid}-panel`;93
94 const panelVariants = {95 enter: (dir: number) =>96 reduce ? { opacity: 0 } : { opacity: 0, x: dir * 28, filter: "blur(6px)", scale: 0.985 },97 center: { opacity: 1, x: 0, filter: "blur(0px)", scale: 1 },98 exit: (dir: number) =>99 reduce ? { opacity: 0 } : { opacity: 0, x: dir * -28, filter: "blur(6px)", scale: 0.985 },100 };101
102 return (103 <div104 className={cn(105 "flex w-full flex-col gap-2.5 rounded-[22px] border border-(--color-rule) bg-(--color-paper-2)/60 p-2.5",106 className,107 )}108 >109 <LayoutGroup id={uid}>110 <div111 role="tablist"112 aria-label="Sections"113 onKeyDown={onKeyDown}114 onPointerLeave={() => setHovered(null)}115 className={cn(116 "relative bg-(--color-paper) p-[3px]",117 "shadow-[inset_0_0_0_1px_var(--color-rule),0_1px_2px_rgba(0,0,0,0.03)]",118 layout === "grid"119 ? "grid w-full grid-cols-2 gap-[3px] rounded-[14px] sm:grid-cols-3"120 : "flex w-fit max-w-full gap-[3px] overflow-x-auto rounded-full [scrollbar-width:none]",121 )}122 >123 {tabs.map((tab) => {124 const isActive = tab.id === activeTab.id;125 const isHovered = hovered === tab.id && !isActive;126 return (127 <button128 key={tab.id}129 ref={(el) => {130 if (el) tabRefs.current.set(tab.id, el);131 else tabRefs.current.delete(tab.id);132 }}133 type="button"134 role="tab"135 id={tabId(tab.id)}136 aria-selected={isActive}137 aria-controls={panelId}138 tabIndex={isActive ? 0 : -1}139 onClick={() => select(tab.id)}140 onPointerEnter={() => setHovered(tab.id)}141 className={cn(142 "group relative inline-flex h-9 shrink-0 items-center justify-center gap-1.5 px-3.5 text-[13px] font-medium outline-none",143 "transition-[color,transform] duration-200 ease-out active:scale-[0.97]",144 "focus-visible:ring-2 focus-visible:ring-(--color-focus) focus-visible:ring-offset-2 focus-visible:ring-offset-(--color-paper)",145 radius,146 isActive ? "text-(--color-paper)" : "text-(--color-neutral) hover:text-(--color-ink)",147 )}148 >149 {}150 <AnimatePresence>151 {isHovered && (152 <motion.span153 layoutId="hover"154 aria-hidden155 initial={{ opacity: 0 }}156 animate={{ opacity: 1 }}157 exit={{ opacity: 0, transition: { duration: 0.12 } }}158 transition={reduce ? { duration: 0 } : { ...PILL_SPRING, opacity: { duration: 0.15 } }}159 className={cn("absolute inset-0 bg-(--color-paper-2)", radius)}160 />161 )}162 </AnimatePresence>163
164 {}165 {isActive && (166 <motion.span167 layoutId="pill"168 aria-hidden169 transition={reduce ? { duration: 0 } : PILL_SPRING}170 className={cn(171 "absolute inset-0 bg-(--color-ink)",172 "shadow-[inset_0_1px_0_rgba(255,255,255,0.14),0_1px_2px_rgba(0,0,0,0.12),0_4px_12px_-4px_rgba(0,0,0,0.25)]",173 radius,174 )}175 />176 )}177
178 {tab.icon ? (179 <motion.span180 aria-hidden181 initial={false}182 animate={183 isActive && !reduce184 ? { scale: [0.7, 1.12, 1], rotate: [-12, 4, 0], filter: ["blur(2px)", "blur(0px)", "blur(0px)"] }185 : { scale: 1, rotate: 0, filter: "blur(0px)" }186 }187 transition={{ duration: 0.4, ease: EASE_OUT }}188 className="relative flex size-3.5 shrink-0 items-center justify-center [&_svg]:size-full [&_svg]:stroke-[1.75]"189 >190 {tab.icon}191 </motion.span>192 ) : null}193 <span className="relative truncate">{tab.label}</span>194 </button>195 );196 })}197 </div>198 </LayoutGroup>199
200 <motion.div201 role="tabpanel"202 id={panelId}203 aria-labelledby={tabId(activeTab.id)}204 tabIndex={0}205 initial={false}206 animate={{ height }}207 transition={reduce ? { duration: 0 } : HEIGHT_SPRING}208 className={cn(209 "relative w-full overflow-hidden rounded-[16px] bg-(--color-paper) outline-none",210 "shadow-[inset_0_0_0_1px_var(--color-rule),0_1px_2px_rgba(0,0,0,0.03)]",211 "focus-visible:ring-2 focus-visible:ring-(--color-focus)",212 )}213 >214 <div ref={innerRef} className="relative">215 <AnimatePresence initial={false} custom={direction} mode="popLayout">216 <motion.div217 key={activeTab.id}218 custom={direction}219 variants={panelVariants}220 initial="enter"221 animate="center"222 exit="exit"223 transition={{ duration: 0.32, ease: EASE_OUT }}224 className="p-4 sm:p-5"225 >226 {showTitle ? (227 <motion.div228 initial={reduce ? false : { opacity: 0, y: 4 }}229 animate={{ opacity: 1, y: 0 }}230 transition={{ duration: 0.3, ease: EASE_OUT, delay: 0.04 }}231 className="text-[15px] leading-snug font-semibold text-balance text-(--color-ink)"232 >233 {activeTab.label}234 </motion.div>235 ) : null}236 <motion.div237 initial={reduce ? false : { opacity: 0, y: 6 }}238 animate={{ opacity: 1, y: 0 }}239 transition={{ duration: 0.32, ease: EASE_OUT, delay: 0.08 }}240 className={cn("text-[14px] leading-relaxed text-(--color-ink-2)", showTitle && "mt-1.5")}241 >242 {activeTab.content}243 </motion.div>244 </motion.div>245 </AnimatePresence>246 </div>247 </motion.div>248 </div>249 );250}251
252253export const AnimatedMenu = AnimatedTabs;