1"use client";2
3import React, {4 useCallback,5 useEffect,6 useLayoutEffect,7 useRef,8 useState,9} from "react";10import { cn } from "@/lib/utils";11
12export type TabItem = {13 id: string | number;14 label: string;15 icon?: React.ReactNode;16 content: React.ReactNode;17};18
19export type AnimatedTabsProps = {20 tabs: TabItem[];21 defaultActiveId?: string | number;22 layout?: "horizontal" | "grid";23 className?: string;24};25
26export function AnimatedTabs({27 tabs,28 defaultActiveId,29 layout = "horizontal",30 className = "",31}: AnimatedTabsProps) {32 const [activeId, setActiveId] = useState(defaultActiveId ?? tabs[0]?.id);33 const barRef = useRef<HTMLDivElement>(null);34 const pillRef = useRef<HTMLSpanElement>(null);35 const panelInnerRef = useRef<HTMLDivElement>(null);36 const [panelHeight, setPanelHeight] = useState<number | undefined>(undefined);37 const animatePill = useRef(false);38
39 const moveTo = useCallback((tab: HTMLElement | null, animate: boolean) => {40 const pill = pillRef.current;41 const bar = barRef.current;42 if (!pill || !tab || !bar) return;43
44 45 const next = {46 transform: `translate(${tab.offsetLeft}px, ${tab.offsetTop}px)`,47 width: `${tab.offsetWidth}px`,48 height: `${tab.offsetHeight}px`,49 };50
51 if (!animate) {52 const prev = pill.style.transition;53 pill.style.transition = "none";54 pill.style.transform = next.transform;55 pill.style.width = next.width;56 pill.style.height = next.height;57 void pill.offsetWidth;58 pill.style.transition = prev;59 } else {60 pill.style.transform = next.transform;61 pill.style.width = next.width;62 pill.style.height = next.height;63 }64 }, []);65
66 const activeTabEl = useCallback(() => {67 const bar = barRef.current;68 if (!bar) return null;69 return (70 bar.querySelector<HTMLElement>('.t-tab[aria-selected="true"]') ??71 bar.querySelector<HTMLElement>(".t-tab")72 );73 }, []);74
75 useLayoutEffect(() => {76 moveTo(activeTabEl(), animatePill.current);77 animatePill.current = false;78 }, [activeId, tabs, layout, moveTo, activeTabEl]);79
80 useLayoutEffect(() => {81 const node = panelInnerRef.current;82 if (!node) return;83 setPanelHeight(node.getBoundingClientRect().height);84 }, [activeId, tabs]);85
86 useEffect(() => {87 const onResize = () => {88 moveTo(activeTabEl(), false);89 const node = panelInnerRef.current;90 if (node) setPanelHeight(node.getBoundingClientRect().height);91 };92 window.addEventListener("resize", onResize);93 return () => window.removeEventListener("resize", onResize);94 }, [moveTo, activeTabEl]);95
96 const setTab = (id: string | number) => {97 if (id === activeId) return;98 animatePill.current = true;99 setActiveId(id);100 };101
102 const activeTab = tabs.find((t) => t.id === activeId);103
104 return (105 <div106 className={cn(107 "flex w-full flex-col gap-3 rounded-xl border border-(--color-rule)",108 "bg-(--color-paper-2) p-3 sm:p-4",109 className,110 )}111 >112 <div113 ref={barRef}114 role="tablist"115 aria-label="Sections"116 className={cn(117 "t-tabs",118 layout === "grid"119 ? "!grid w-full grid-cols-2 gap-1 sm:grid-cols-3"120 : "w-fit max-w-full flex-wrap",121 )}122 style={123 {124 borderRadius: layout === "grid" ? "12px" : "999px",125 padding: 3,126 gap: 3,127 "--tabs-bar-bg": "var(--color-paper)",128 "--tabs-pill-bg": "var(--color-ink)",129 "--tabs-text-muted": "var(--color-ink-muted)",130 131 "--tabs-text-active": "var(--color-ink)",132 "--tabs-dur": "220ms",133 "--tabs-ease": "cubic-bezier(0.22, 1, 0.36, 1)",134 background: "var(--color-paper)",135 boxShadow: "inset 0 0 0 1px var(--color-rule)",136 } as React.CSSProperties137 }138 >139 <span140 ref={pillRef}141 className="t-tabs-pill"142 style={{143 top: 0,144 left: 0,145 height: 0,146 borderRadius: layout === "grid" ? "10px" : "999px",147 background: "var(--tabs-pill-bg)",148 boxShadow: "0 1px 2px rgba(0,0,0,0.06)",149 transition:150 "transform var(--tabs-dur) var(--tabs-ease), width var(--tabs-dur) var(--tabs-ease), height var(--tabs-dur) var(--tabs-ease)",151 }}152 aria-hidden="true"153 />154 {tabs.map((tab) => {155 const isActive = tab.id === activeId;156 return (157 <button158 key={tab.id}159 type="button"160 role="tab"161 aria-selected={isActive}162 aria-controls={`panel-${tab.id}`}163 id={`tab-${tab.id}`}164 onClick={() => setTab(tab.id)}165 className={cn(166 "t-tab relative z-1 inline-flex items-center justify-center gap-1.5",167 "rounded-full px-3 text-[13px] font-medium outline-none",168 "transition-colors duration-[var(--tabs-dur)] ease-[var(--tabs-ease)]",169 "focus-visible:ring-2 focus-visible:ring-(--color-focus) focus-visible:ring-offset-2 focus-visible:ring-offset-(--color-paper-2)",170 "active:scale-[0.98]",171 )}172 style={{173 height: 36,174 background: "transparent",175 176 color: isActive177 ? "var(--color-paper)"178 : "var(--tabs-text-muted)",179 borderRadius: layout === "grid" ? 10 : 999,180 }}181 >182 {tab.icon ? (183 <span className="flex size-3.5 shrink-0 items-center justify-center [&_svg]:size-full [&_svg]:stroke-[1.75]">184 {tab.icon}185 </span>186 ) : null}187 <span className="truncate">{tab.label}</span>188 </button>189 );190 })}191 </div>192
193 <div194 role="tabpanel"195 id={`panel-${activeId}`}196 aria-labelledby={`tab-${activeId}`}197 className={cn(198 "t-resize w-full overflow-hidden rounded-lg border border-(--color-rule)",199 "bg-(--color-paper)",200 )}201 style={{202 height: panelHeight,203 204 ["--resize-dur" as string]: "240ms",205 ["--resize-ease" as string]: "cubic-bezier(0.22, 1, 0.36, 1)",206 }}207 >208 <div ref={panelInnerRef} className="p-4 sm:p-5">209 <div210 key={activeId}211 className="menu-panel-enter"212 style={213 {214 ["--menu-enter-dur" as string]: "200ms",215 } as React.CSSProperties216 }217 >218 <h3 className="text-balance text-[15px] font-semibold leading-snug text-(--color-ink)">219 {activeTab?.label}220 </h3>221 <div className="mt-1.5 text-sm leading-relaxed text-(--color-ink-2)">222 {activeTab?.content}223 </div>224 </div>225 </div>226 </div>227 </div>228 );229}