diff --git a/site/src/components/Tabs/Tabs.tsx b/site/src/components/Tabs/Tabs.tsx index 344379e089..ec823d0afe 100644 --- a/site/src/components/Tabs/Tabs.tsx +++ b/site/src/components/Tabs/Tabs.tsx @@ -1,4 +1,13 @@ -import { createContext, type FC, type HTMLAttributes, useContext } from "react"; +import { + createContext, + type FC, + type HTMLAttributes, + useCallback, + useContext, + useEffect, + useLayoutEffect, + useRef, +} from "react"; import { Link, type LinkProps } from "react-router"; import { cn } from "utils/cn"; @@ -32,12 +41,71 @@ export const Tabs: FC = ({ className, active, ...htmlProps }) => { type TabsListProps = HTMLAttributes; export const TabsList: FC = ({ className, ...props }) => { + const tabsContext = useContext(TabsContext); + const listRef = useRef(null); + const indicatorRef = useRef(null); + const hasInitialized = useRef(false); + + const updateIndicator = useCallback((animate: boolean) => { + const list = listRef.current; + const indicator = indicatorRef.current; + if (!list || !indicator) return; + + const activeTab = list.querySelector("[data-active='true']"); + if (!activeTab) { + indicator.style.opacity = "0"; + return; + } + + const listRect = list.getBoundingClientRect(); + const activeRect = activeTab.getBoundingClientRect(); + + if (!animate) { + indicator.style.transition = "none"; + } + + indicator.style.left = `${activeRect.left - listRect.left}px`; + indicator.style.width = `${activeRect.width}px`; + indicator.style.opacity = "1"; + + if (!animate) { + // Force a reflow so the position applies before + // restoring the transition property. + void indicator.offsetHeight; + indicator.style.transition = ""; + } + }, []); + + // Measure synchronously before paint so the indicator is + // positioned correctly on the first frame. Animate only on + // subsequent active-tab changes. + const active = tabsContext?.active; + useLayoutEffect(() => { + // Re-run whenever the active tab changes. + void active; + updateIndicator(hasInitialized.current); + hasInitialized.current = true; + }, [active, updateIndicator]); + + // Reposition without animation on window resize. + useEffect(() => { + const handleResize = () => updateIndicator(false); + window.addEventListener("resize", handleResize); + return () => window.removeEventListener("resize", handleResize); + }, [updateIndicator]); + return ( -
+
+
+
+
); }; @@ -59,15 +127,13 @@ export const TabLink: FC = ({ return (