← Back to Blog

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.

Eduardo CalvoEduardo Calvo
··1 min read

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 }}/>
Live PreviewStep 1/6

Key Takeaways

After building this component, you've learned:

  1. layoutId is Motion's magic for shared-element transitions
  2. role="tablist" + aria-selected gives you accessibility for free
  3. Arrow keys + Home/End are expected in tab widgets — don't skip them
  4. Spring with bounce: 0.05 keeps the slide tight and professional
  5. Reduce motion = duration: 0 — instant, not "soft"
  6. Unique layoutId per instance prevents cross-talk when you render multiple tab groups

Install the Component

npx smoothui-cli add animated-tabs

Check out the full documentation for all props and variations.

Share:

More Posts

Open source & free

Support SmoothUI

Help keep SmoothUI free and actively maintained. Every sponsor helps ship more components, blocks, and animation recipes.