Building Animated Tabs with a Shared Layout Indicator
How to build tabs where a single pill slides smoothly between the active option, with full keyboard support and reduced-motion compliance.
In this tutorial, we'll build the Animated Tabs component step by step. Click between tabs in the preview to see the shared indicator slide as each layer is added.
Semantic Markup
Start with role=tablist + role=tab so keyboard users and screen readers work out of the box.
<div role="tablist"> {tabs.map((tab) => ( <button key={tab.id} role="tab" aria-selected={active === tab.id}> {tab.label} </button> ))}</div>Active State
Track which tab is active and style it differently.
const [active, setActive] = useState(tabs[0].id);<button role="tab" aria-selected={active === tab.id} className={active === tab.id ? "text-foreground" : "text-muted-foreground"} onClick={() => setActive(tab.id)}>Shared Indicator
Add a motion.span with a shared layoutId inside the active tab — Motion handles the slide for you.
{active === tab.id && ( <motion.span layoutId="tabs-indicator" className="absolute inset-0 rounded-full bg-background shadow-sm" />)}Spring Transition
Use a snappy spring (bounce ≤ 0.1) so the indicator feels tight, not floaty.
<motion.span layoutId="tabs-indicator" transition={{ type: "spring", duration: 0.25, bounce: 0.05 }}/>Keyboard Navigation
Handle Arrow keys, Home and End. Move focus to the newly active tab.
const onKeyDown = (e: KeyboardEvent, i: number) => { if (e.key === "ArrowRight") setActive(tabs[(i + 1) % tabs.length].id); if (e.key === "ArrowLeft") setActive(tabs[(i - 1 + tabs.length) % tabs.length].id); if (e.key === "Home") setActive(tabs[0].id); if (e.key === "End") setActive(tabs[tabs.length - 1].id);};Reduced Motion
Skip the spring entirely when the user has prefers-reduced-motion set.
const shouldReduceMotion = useReducedMotion();<motion.span layoutId="tabs-indicator" transition={shouldReduceMotion ? { duration: 0 } : { type: "spring", duration: 0.25, bounce: 0.05 }}/>Key Takeaways
After building this component, you've learned:
layoutIdis Motion's magic for shared-element transitionsrole="tablist"+aria-selectedgives you accessibility for free- Arrow keys + Home/End are expected in tab widgets — don't skip them
- Spring with
bounce: 0.05keeps the slide tight and professional - Reduce motion =
duration: 0— instant, not "soft" - Unique
layoutIdper instance prevents cross-talk when you render multiple tab groups
Install the Component
npx smoothui-cli add animated-tabsCheck out the full documentation for all props and variations.