Animated Tab Indicator: Sliding Underline with Layout Animation
Build a smooth sliding tab underline using Framer Motion's layoutId — no CSS hacks, no janky transitions. Real code, real measurements, zero libraries bloat.
Why Most Tab Indicators Look Wrong
Honestly, the default CSS transition approach for tab underlines has always been a hack. You set left and width with JavaScript, attach a transition: left 0.2s ease, and then spend the next hour debugging why the animation jumps on first render or stutters when fonts load late. It's a solved problem — just not solved elegantly.
The real issue is that CSS transitions on left/width don't account for the browser's layout pass. The element's position is computed relative to its parent, and if anything in the tab label changes — font weight on active state, padding tweaks, an icon appearing — the numbers shift. You end up with a bunch of brittle getBoundingClientRect() calls and useEffect dependencies that are always one step behind.
There's a better approach using Framer Motion's layoutId API. It lets the browser's layout engine do the measuring, and Framer Motion handles the interpolation between positions across renders. No manual coordinate tracking. No resize observers for the basic case. It just works — and when it doesn't, the failure mode is obvious rather than mysterious.
How Framer Motion layoutId Actually Works
The layoutId prop tells Framer Motion: "this element and that element are the same thing, just in different positions." When the component re-renders and the element moves to a new DOM position, Framer Motion captures the before and after bounding boxes and animates between them using a FLIP (First, Last, Invert, Play) technique under the hood.
FLIP is what makes this feel natural. Instead of animating a CSS property from value A to value B — which requires the browser to compute intermediate states on every frame — FLIP records where the element was, moves it instantly to the new position, then applies a transform that makes it look like it's still in the old spot. The animation plays the transform back to zero, which is always GPU-accelerated. You get silky 60fps with no layout thrashing.
One thing worth understanding: layoutId works across component unmounts and remounts too. If you conditionally render the indicator pill rather than just moving it, Framer Motion can still animate it. That matters if you're building tabs where some states don't show an indicator at all — for accessibility reasons, or for a design that hides the underline on a specific view.
Setting Up the Tab Component Structure
Before writing any animation code, get the data model right. Tabs are just an array of objects — id, label, and optionally an icon. The active tab is a single string (the id). That's it. Don't reach for a library for this.
Here's the base structure with Tailwind v4.0.2 classes and the Framer Motion indicator:
import { useState } from 'react';
import { motion } from 'framer-motion';
const TABS = [
{ id: 'overview', label: 'Overview' },
{ id: 'features', label: 'Features' },
{ id: 'pricing', label: 'Pricing' },
{ id: 'docs', label: 'Docs' },
];
export function AnimatedTabs() {
const [active, setActive] = useState('overview');
return (
<div className="flex gap-1 border-b border-white/10 relative">
{TABS.map((tab) => (
<button
key={tab.id}
onClick={() => setActive(tab.id)}
className={[
'relative px-4 py-2 text-sm font-medium transition-colors duration-150',
active === tab.id
? 'text-white'
: 'text-white/50 hover:text-white/80',
].join(' ')}
>
{tab.label}
{active === tab.id && (
<motion.div
layoutId="tab-indicator"
className="absolute bottom-0 left-0 right-0 h-0.5 bg-violet-500"
style={{ borderRadius: '2px 2px 0 0' }}
transition={{ type: 'spring', stiffness: 500, damping: 40 }}
/>
)}
</button>
))}
</div>
);
}The layoutId="tab-indicator" on the motion.div is the entire animation. When active changes, the div unmounts from the old tab button and mounts inside the new one — Framer Motion sees two elements with the same layoutId appear and disappear in the same frame and smoothly animates between their positions. The spring config (stiffness: 500, damping: 40) gives a snappy feel without overshoot.
Tuning the Spring Physics for Different Feels
The transition prop on that motion.div is where personality lives. Framer Motion's spring animation takes three main parameters: stiffness, damping, and optionally mass. Higher stiffness makes it faster. Higher damping reduces bounce. Mass affects momentum — heavier elements accelerate slower but feel more physical.
For a tight, snappy SaaS dashboard feel: stiffness: 500, damping: 40. For a slightly bouncier, more playful UI: stiffness: 300, damping: 25. If you want the indicator to "drag" behind the cursor like a physical object: stiffness: 200, damping: 20, mass: 0.8. You can drop these numbers into a Framer Motion spring visualizer to feel them out before committing to code.
One pattern that looks sharp: animate the color alongside the slide. Add a motion.div behind the active tab label as a background pill with the same layoutId trick, using rgba(139, 92, 246, 0.12) as the fill. Now the background highlight and the underline both slide together. It reads as a single coherent object moving through space rather than two separate CSS transitions firing independently.
What's the right approach for tabs that need to work without JavaScript? Fall back to aria-selected CSS. Style [aria-selected=true]::after with a bottom border. The JS-animated version layers on top as a progressive enhancement. This is the same principle that makes theme toggle implementations in React feel solid — build the functional base first, animate second.
Accessible Tab Semantics Without Breaking the Animation
ARIA roles matter here. A tab component needs role="tablist" on the container, role="tab" on each button, aria-selected on the active one, and keyboard navigation — arrow keys to move between tabs, Enter/Space to activate. Framer Motion doesn't touch any of this, so you have to wire it up yourself.
The keyboard handler is straightforward. On ArrowRight, move to the next tab id in the array (wrapping around). On ArrowLeft, move to the previous. On Home/End, jump to first or last. Keep a ref array of the button elements so you can call .focus() on the target after updating active. That focus call is what makes screen readers announce the tab change.
One thing you can't do: use the visual indicator as the only active state signal. Some users have prefers-reduced-motion enabled. Framer Motion respects this by default — it'll snap to the new position instantly instead of animating. But you still need the text-white color change and the aria-selected attribute to communicate which tab is active. The animation is decoration. The semantics are the function.
Handling Overflow: When There Are Too Many Tabs
Mobile is where tab components fall apart. Eight tabs in a row at 375px viewport width means either tiny unreadable labels or horizontal overflow. The scroll approach — overflow-x: auto with scrollbar-width: none — works but kills the sliding indicator, because the indicator's position is relative to the scroll container's coordinate system, not the viewport.
The fix: keep the indicator as a child of each tab button (as shown in the code above), not as an absolutely positioned sibling of the whole list. When the indicator is inside the button, its bottom: 0 is relative to the button, not the scroll container. The Framer Motion FLIP still works because it measures in document coordinates and the transform compensates correctly.
Is it worth adding a scroll shadow — a gradient fade on the left and right edges of the tab bar to signal that there's more content to scroll to? Yes, for anything with more than 5 tabs. Use a ::before and ::after pseudo-element on the container with pointer-events: none and a linear gradient from the container's background color to transparent. 24px of fade is usually enough. Check out how glassmorphism component implementations handle edge fading for a similar translucent-overlay pattern.
Combining the Indicator with Other Visual Styles
The sliding underline works with basically any UI aesthetic. In a glassmorphism context, make the indicator a blurred gradient strip: bg-white/30 backdrop-blur-sm with a 1px height and a glow shadow. In a neobrutalism layout, make it a solid 3px black bar with zero border-radius and no spring animation — just an instant position swap. The layoutId approach handles both because you're just changing the styles on the motion.div.
For dark mode vs light mode, don't hardcode the indicator color. Use a CSS custom property: style={{ backgroundColor: 'var(--tab-indicator-color)' }} and set that variable in your theme tokens. If you're using the approach from Tailwind vs CSS Modules comparisons, CSS custom properties let you switch the entire tab palette in one line at the :root level without touching the component.
The most underrated combination: pair the sliding indicator with a subtle scale animation on the tab label text. When a tab becomes active, scale the text from 1 to 1.02 over 150ms. It's barely perceptible consciously but it adds a sense of weight to the active state. Use a motion.span wrapping the label text with animate={{ scale: active === tab.id ? 1.02 : 1 }}.
Performance Considerations for Tab-Heavy Dashboards
If you're building a dashboard with dozens of tab groups on the same page, a few things matter. First, each motion.div with layoutId creates a Framer Motion context entry. It's lightweight, but at scale you'll notice it. Use the LayoutGroup component from Framer Motion to scope layoutId namespaces — otherwise two tab groups on the same page that both use "tab-indicator" as a layoutId will interfere with each other, animating between the wrong elements.
import { LayoutGroup, motion } from 'framer-motion';
// Wrap each independent tab group in its own LayoutGroup
<LayoutGroup id="sidebar-tabs">
<AnimatedTabs tabs={sidebarTabs} />
</LayoutGroup>
<LayoutGroup id="content-tabs">
<AnimatedTabs tabs={contentTabs} />
</LayoutGroup>Second: don't animate tab content switches with heavy layout animations. The tab indicator sliding is cheap because it's a tiny 2px element. Animating the tab panel content — fading in a large DOM subtree — can cause paint storms. Prefer opacity transitions on tab panels over height or transform animations. If the content is complex, consider React.lazy + Suspense per tab panel so you're not mounting all panels on load.
FAQ
Pure CSS works if all your tabs are equal width. Set the indicator as an absolutely positioned element and use calc() with a CSS custom property for the offset: transform: translateX(calc(var(--active-index) * 100%)). Update the custom property with JavaScript. It breaks down with variable-width tabs — you'd need getBoundingClientRect() anyway, at which point Framer Motion's layoutId is cleaner and less error-prone.
Framer Motion's FLIP needs a previous position to animate from. On first render, there's no previous position, so it appears instantly — which is actually correct behavior. If you want it to animate in on mount, add initial={{ opacity: 0 }} and animate={{ opacity: 1 }} to the motion.div alongside the layoutId. That gives you a fade-in on first appearance without fighting the FLIP logic.
Wrap each tab group in Framer Motion's <LayoutGroup id="unique-id"> component. The LayoutGroup scopes layoutId values so "tab-indicator" in one group is completely separate from "tab-indicator" in another. Without this, Framer Motion will try to animate between indicators across different tab components, causing very confusing cross-component sliding.
Apple's tab indicators use a critically damped spring — no overshoot, fast settle. In Framer Motion terms: { type: 'spring', stiffness: 380, damping: 35 }. This settles in roughly 200ms with no bounce. For Android Material You's indicator (which has a slight stretch effect), you'd also need to animate the scaleX of the indicator as it moves — expand it slightly mid-transition, then contract to the target width.
You don't have to. That's the beauty of the layoutId approach. Because the motion.div is rendered inside each tab button and sized with left: 0; right: 0, it automatically takes the width of its parent button. Framer Motion measures both the source and destination bounding boxes (including their different widths) and interpolates between them. Width animation comes for free.
The tab component itself needs to be a client component ('use client' directive) because it uses useState and Framer Motion. Mark it as a client component, then import it into your server component page. The tab panels can still be server-rendered HTML — only the tab bar with the animation logic needs to run client-side. Keep the animated shell thin and push as much content as possible into the server-rendered panels.