Animated Tabs
Animated tabs use a sliding indicator to show which tab is active. The indicator smoothly transitions between tabs, creating a polished feel that makes the interface feel responsive.
By the end of this post, we'll build animated tabs with a sliding indicator using only CSS transitions — no animation libraries required.
What We're Building
Before diving in, let's define our acceptance criteria:
- Smooth sliding indicator that follows the active tab
- Handle dynamic tab widths
- Work with any number of tabs
- Zero animation library dependencies
The Approach
The trick is to use a single indicator element positioned absolutely, then update its left and width via inline styles when the active tab changes. CSS transition handles the animation.
We need to:
- Track which tab is active
- Measure the active tab's position and width
- Apply those values to the indicator
Measuring Tab Position
We'll use refs to measure each tab button. When the active tab changes, we read its offsetLeft and offsetWidth:
const [activeTab, setActiveTab] = useState(tabs[0].id);const [indicatorStyle, setIndicatorStyle] = useState({ left: 0, width: 0 });const tabRefs = useRef<Map<string, HTMLButtonElement>>(new Map());useEffect(() => {const activeElement = tabRefs.current.get(activeTab);if (activeElement) {setIndicatorStyle({left: activeElement.offsetLeft,width: activeElement.offsetWidth,});}}, [activeTab]);
We store refs in a Map so we can look up any tab by its ID. This is cleaner than managing an array of refs when tabs can be dynamic.
The Indicator Element
The indicator is positioned absolutely within the tab container:
<div className="relative flex gap-1 rounded-full bg-gray-100 p-1">{/* Sliding indicator */}<spanclassName="absolute top-1 bottom-1 rounded-full bg-white shadow-sm transition-all duration-300"style={{ left: indicatorStyle.left, width: indicatorStyle.width }}/>{/* Tab buttons */}{tabs.map((tab) => (<buttonkey={tab.id}ref={(el) => el && tabRefs.current.set(tab.id, el)}onClick={() => setActiveTab(tab.id)}className="relative z-10 rounded-full px-3 py-1.5">{tab.label}</button>))}</div>
The transition-all duration-300 class handles the animation. When left or width changes, CSS smoothly interpolates between values.
Handling Initial Render
There's a catch: on first render, the refs haven't been measured yet, so the indicator has no position. We need to measure after mount:
useLayoutEffect(() => {const activeElement = tabRefs.current.get(activeTab);if (activeElement) {setIndicatorStyle({left: activeElement.offsetLeft,width: activeElement.offsetWidth,});}}, [activeTab]);
Using useLayoutEffect instead of useEffect ensures the measurement happens
before the browser paints, avoiding a flash of the indicator at position 0.
Final Code
Here's the complete implementation:
import { useLayoutEffect, useRef, useState } from "react";const tabs = [{ id: "account", label: "Account" },{ id: "password", label: "Password" },{ id: "settings", label: "Settings" },];export function AnimatedTabs() {const [activeTab, setActiveTab] = useState(tabs[0].id);const [indicatorStyle, setIndicatorStyle] = useState({ left: 0, width: 0 });const tabRefs = useRef<Map<string, HTMLButtonElement>>(new Map());useLayoutEffect(() => {const activeElement = tabRefs.current.get(activeTab);if (activeElement) {setIndicatorStyle({left: activeElement.offsetLeft,width: activeElement.offsetWidth,});}}, [activeTab]);return (<div className="relative flex gap-1 rounded-full bg-gray-100 p-1"><spanclassName="absolute top-1 bottom-1 rounded-full bg-white shadow-sm transition-all duration-300"style={{ left: indicatorStyle.left, width: indicatorStyle.width }}/>{tabs.map((tab) => (<buttonkey={tab.id}ref={(el) => {if (el) tabRefs.current.set(tab.id, el);}}onClick={() => setActiveTab(tab.id)}className="relative z-10 rounded-full px-3 py-1.5 text-sm font-medium text-gray-600 transition-colors hover:text-gray-900">{tab.label}</button>))}</div>);}
Conclusion
We built animated tabs using only React state and CSS transitions:
- Refs measure the active tab's position and width
- Inline styles position the indicator
- CSS
transitionanimates between states - No animation libraries needed
This pattern works anywhere you need a sliding indicator — navigation menus, segmented controls, filter bars. The key insight is that CSS transitions can animate any numeric style property, so we just need to provide the start and end values.
Resources
- CSS Transitions — MDN reference
- useLayoutEffect — React docs on synchronous effects