How toCreate Animated Tab Indicators with CSS Variables

Tab components are common in web applications, from navigation menus to settings panels. A static indicator that jumps between tabs works, but an animated indicator makes the selected tab easier to track.

The challenge is making the indicator respond to dynamic content: tabs can have different widths, the selected tab can change, and the container might resize. You need a way to track the selected tab's position and size in real time, then update the indicator without causing layout thrashing or janky animations.

The solution is to use CSS variables for the indicator's position and size, then update those variables using ResizeObserver and MutationObserver. This approach keeps the animation logic in CSS while JavaScript handles the measurements.

Set Up the Tabs Component

Start with a basic tabs component using React Aria Components. The TabList component will hold the indicator logic:

app/components/tabs.tsx
import type { ComponentProps } from "react"; import { useEffect, useRef } from "react"; import { composeRenderProps, Tabs as AriaTabs, TabList as AriaTabList, Tab as AriaTab, TabPanel as AriaTabPanel, } from "react-aria-components"; export function Tabs({ className, ...props }: ComponentProps<typeof AriaTabs>) { return ( <AriaTabs {...props} className={composeRenderProps(className, (className) => ["tabs", className].filter(Boolean).join(" "), )} /> ); } export function TabList({ className, ...props }: ComponentProps<typeof AriaTabList>) { let listRef = useRef<HTMLDivElement>(null); // Indicator logic will go here return ( <AriaTabList ref={listRef} {...props} className={composeRenderProps(className, (className) => ["tab-list", className].filter(Boolean).join(" "), )} /> ); } export function Tab({ className, ...props }: ComponentProps<typeof AriaTab>) { return ( <AriaTab {...props} className={composeRenderProps(className, (className) => ["tab", className].filter(Boolean).join(" "), )} /> ); } export function TabPanel({ className, ...props }: ComponentProps<typeof AriaTabPanel>) { return ( <AriaTabPanel {...props} className={composeRenderProps(className, (className) => ["tab-panel", className].filter(Boolean).join(" "), )} /> ); }

This sets up the basic structure. React Aria Components handles accessibility, keyboard navigation, and adds a data-selected attribute to the active tab.

Calculate the Indicator Position

The indicator needs to know the selected tab's position relative to the tab list. Create a function that reads the selected tab's bounding rect and sets CSS variables on the list element:

app/components/tabs.tsx
let updateIndicator = () => { let list = listRef.current; if (!list) return; let selected = list.querySelector<HTMLElement>("[data-selected]"); if (!selected) { list.style.setProperty("--tab-indicator-opacity", "0"); return; } let listRect = list.getBoundingClientRect(); let selectedRect = selected.getBoundingClientRect(); let left = selectedRect.left - listRect.left + list.scrollLeft; list.style.setProperty("--tab-indicator-left", `${left}px`); list.style.setProperty("--tab-indicator-width", `${selectedRect.width}px`); list.style.setProperty("--tab-indicator-opacity", "1"); };

The function queries for the element with data-selected, calculates its offset from the list container, and sets three CSS variables: --tab-indicator-left for horizontal position, --tab-indicator-width for size, and --tab-indicator-opacity to hide the indicator when no tab is selected.

Adding list.scrollLeft ensures the calculation stays correct when the tab list is scrollable.

Observe Selection Changes with MutationObserver

The indicator needs to update whenever the selected tab changes. React Aria Components toggles the data-selected attribute, so you can watch for that change with a MutationObserver:

app/components/tabs.tsx
useEffect(() => { let list = listRef.current; if (!list) return; let updateIndicator = () => { // ... calculation logic from above }; let mutationObserver = new MutationObserver(updateIndicator); mutationObserver.observe(list, { subtree: true, attributes: true, attributeFilter: ["data-selected"], }); updateIndicator(); // Initial calculation return () => { mutationObserver.disconnect(); }; }, []);

The attributeFilter option limits the observer to only fire when data-selected changes, avoiding unnecessary updates from other attribute changes.

Handle Resize with ResizeObserver

Tab widths can change when fonts load, content updates, or the viewport resizes. Use ResizeObserver to recalculate when sizes change:

app/components/tabs.tsx
useEffect(() => { let list = listRef.current; if (!list) return; let observedSelected: HTMLElement | null = null; let updateIndicator = () => { let selected = list.querySelector<HTMLElement>("[data-selected]"); // Track which element we're observing for resize if (selected !== observedSelected) { if (observedSelected) resizeObserver.unobserve(observedSelected); if (selected) resizeObserver.observe(selected); observedSelected = selected; } // ... rest of calculation logic }; let resizeObserver = new ResizeObserver(updateIndicator); let mutationObserver = new MutationObserver(updateIndicator); resizeObserver.observe(list); // Watch the list container mutationObserver.observe(list, { subtree: true, attributes: true, attributeFilter: ["data-selected"], }); updateIndicator(); return () => { resizeObserver.disconnect(); mutationObserver.disconnect(); }; }, []);

The ResizeObserver watches both the list container and the currently selected tab. When the selection changes, it stops observing the old tab and starts observing the new one.

Optimize with requestAnimationFrame

Multiple resize or mutation events can fire in quick succession. Use requestAnimationFrame to batch updates and avoid layout thrashing:

app/components/tabs.tsx
useEffect(() => { let list = listRef.current; if (!list) return; let frame = 0; let observedSelected: HTMLElement | null = null; let updateIndicator = () => { let selected = list.querySelector<HTMLElement>("[data-selected]"); if (selected !== observedSelected) { if (observedSelected) resizeObserver.unobserve(observedSelected); if (selected) resizeObserver.observe(selected); observedSelected = selected; } if (!selected) { list.style.setProperty("--tab-indicator-opacity", "0"); return; } let listRect = list.getBoundingClientRect(); let selectedRect = selected.getBoundingClientRect(); let left = selectedRect.left - listRect.left + list.scrollLeft; list.style.setProperty("--tab-indicator-left", `${left}px`); list.style.setProperty("--tab-indicator-width", `${selectedRect.width}px`); list.style.setProperty("--tab-indicator-opacity", "1"); }; let schedule = () => { cancelAnimationFrame(frame); frame = requestAnimationFrame(updateIndicator); }; let resizeObserver = new ResizeObserver(schedule); let mutationObserver = new MutationObserver(schedule); resizeObserver.observe(list); mutationObserver.observe(list, { subtree: true, attributes: true, attributeFilter: ["data-selected"], }); schedule(); return () => { cancelAnimationFrame(frame); resizeObserver.disconnect(); mutationObserver.disconnect(); }; }, []);

The schedule function cancels any pending frame before requesting a new one. This ensures only one update runs per frame, even if multiple events fire.

Handle Scroll and Resize

The selected tab's position can change when the window resizes or when a scrollable tab list moves. Add resize and scroll listeners with proper cleanup using AbortController:

app/components/tabs.tsx
useEffect(() => { let list = listRef.current; if (!list) return; let frame = 0; let observedSelected: HTMLElement | null = null; let controller = new AbortController(); let updateIndicator = () => { // ... calculation logic }; let schedule = () => { cancelAnimationFrame(frame); frame = requestAnimationFrame(updateIndicator); }; let resizeObserver = new ResizeObserver(schedule); let mutationObserver = new MutationObserver(schedule); resizeObserver.observe(list); mutationObserver.observe(list, { subtree: true, attributes: true, attributeFilter: ["data-selected"], }); schedule(); list.addEventListener("scroll", schedule, { signal: controller.signal }); window.addEventListener("resize", schedule, { signal: controller.signal }); return () => { controller.abort(); cancelAnimationFrame(frame); resizeObserver.disconnect(); mutationObserver.disconnect(); }; }, []);

Using AbortController with the signal option automatically removes both event listeners when the effect cleans up, avoiding manual removeEventListener calls.

Style the Indicator with CSS

Now create the CSS that uses these variables. The indicator is a pseudo-element that transitions smoothly when the variables change:

app/styles.css
.tab-list { position: relative; display: flex; gap: 0.5rem; } .tab-list::after { content: ""; position: absolute; bottom: 0; left: var(--tab-indicator-left, 0); width: var(--tab-indicator-width, 0); height: 2px; background: currentColor; opacity: var(--tab-indicator-opacity, 0); transition: left 150ms ease, width 150ms ease, opacity 150ms ease; pointer-events: none; } .tab { padding: 0.5rem 1rem; cursor: pointer; } .tab[data-selected] { font-weight: 600; }

The transition property animates the indicator's position and width. The pointer-events: none ensures the pseudo-element doesn't interfere with tab clicks.

Support Vertical Orientation

For vertical tabs, calculate top and height instead of left and width:

app/components/tabs.tsx
let updateIndicator = () => { let selected = list.querySelector<HTMLElement>("[data-selected]"); if (selected !== observedSelected) { if (observedSelected) resizeObserver.unobserve(observedSelected); if (selected) resizeObserver.observe(selected); observedSelected = selected; } if (!selected) { list.style.setProperty("--tab-indicator-opacity", "0"); return; } let listRect = list.getBoundingClientRect(); let selectedRect = selected.getBoundingClientRect(); let isVertical = Boolean(list.closest('[data-orientation="vertical"]')); if (isVertical) { let top = selectedRect.top - listRect.top + list.scrollTop; list.style.setProperty("--tab-indicator-top", `${top}px`); list.style.setProperty("--tab-indicator-height", `${selectedRect.height}px`); list.style.removeProperty("--tab-indicator-left"); list.style.removeProperty("--tab-indicator-width"); } else { let left = selectedRect.left - listRect.left + list.scrollLeft; list.style.setProperty("--tab-indicator-left", `${left}px`); list.style.setProperty("--tab-indicator-width", `${selectedRect.width}px`); list.style.removeProperty("--tab-indicator-top"); list.style.removeProperty("--tab-indicator-height"); } list.style.setProperty("--tab-indicator-opacity", "1"); };

React Aria Components sets data-orientation="vertical" on the parent Tabs component when using vertical orientation. The indicator checks for this attribute and adjusts its calculations accordingly.

Update the CSS to handle both orientations:

app/styles.css
.tab-list::after { content: ""; position: absolute; bottom: 0; left: var(--tab-indicator-left, 0); width: var(--tab-indicator-width, 0); height: 2px; background: currentColor; opacity: var(--tab-indicator-opacity, 0); transition: left 150ms ease, top 150ms ease, width 150ms ease, height 150ms ease, opacity 150ms ease; pointer-events: none; } .tabs[data-orientation="vertical"] .tab-list::after { top: var(--tab-indicator-top, 0); bottom: auto; left: 0; width: 2px; height: var(--tab-indicator-height, 0); }

Final Thoughts

This pattern keeps the measurement code in JavaScript and the animation in CSS. The combination of MutationObserver, ResizeObserver, and event listeners keeps the indicator aligned as selection, size, and scroll position change.

The same technique works for navigation highlights, progress indicators, or selection boxes. Anywhere you need to animate an element's position from another element's dimensions, CSS variables with observers keep the CSS and JavaScript responsibilities separate. CSS variables also enable context-aware theming when combined with a color scheme toggle.