Animated tabs in React and shadcn/ui with Motion layoutId
Animated tabs have one moving part: an indicator, either an underline or a filled pill, that slides from the old tab to the new one. It tells the reader where they came from and where they are now, which a plain colour change doesn't.
The usual way to build it is to measure each tab with getBoundingClientRect, store the left offset and width in state, and animate those numbers. It works, but you have to re-measure when the window resizes, when fonts load, when labels change and when the list scrolls.
Motion gives you a shorter route. With a shared layoutId, you never measure anything. This guide builds accessible animated tabs from scratch, then shows how to add the same indicator to the shadcn/ui Tabs component. It uses React 19, TypeScript, Tailwind CSS v4 and Motion (formerly Framer Motion).
npm install motionHow layoutId works
When two motion elements have the same layoutId, Motion treats them as the same element. If one unmounts and another with the same layoutId mounts somewhere else in the same render, Motion animates from the old position and size to the new ones.
So the trick is: render the indicator inside the active tab only. When the active tab changes, the indicator unmounts from the old tab and mounts in the new one, and Motion slides it across. Motion measures the before and after boxes for you on every change, so resizes, font loading and label changes are handled.
The same technique powers a segmented control and a toggle group. Only the roles and keyboard behaviour differ.
Under the hood this is a FLIP animation. Motion animates transform rather than left or width, so the browser doesn't have to recalculate layout on every frame.
The full component
"use client";import { AnimatePresence, motion, useReducedMotion } from "motion/react";import { useId, useRef, useState, type KeyboardEvent, type ReactNode } from "react";type TabItem = { id: string; label: string; content: ReactNode };const spring = { type: "spring", stiffness: 520, damping: 38 } as const;export function AnimatedTabs({ tabs }: { tabs: TabItem[] }) { const [active, setActive] = useState(tabs[0]?.id); const reduced = useReducedMotion(); const group = useId(); const refs = useRef<(HTMLButtonElement | null)[]>([]); const index = tabs.findIndex((t) => t.id === active); function onKeyDown(e: KeyboardEvent<HTMLDivElement>) { const last = tabs.length - 1; const next = e.key === "ArrowRight" ? (index === last ? 0 : index + 1) : e.key === "ArrowLeft" ? (index === 0 ? last : index - 1) : e.key === "Home" ? 0 : e.key === "End" ? last : null; if (next === null) return; e.preventDefault(); setActive(tabs[next].id); refs.current[next]?.focus(); } return ( <div> <div role="tablist" aria-label="Project" onKeyDown={onKeyDown} className="flex gap-1 rounded-lg bg-zinc-100 p-1"> {tabs.map((tab, i) => { const selected = tab.id === active; return ( <button key={tab.id} ref={(el) => { refs.current[i] = el; }} id={`${group}-tab-${tab.id}`} role="tab" type="button" aria-selected={selected} aria-controls={`${group}-panel-${tab.id}`} tabIndex={selected ? 0 : -1} onClick={() => setActive(tab.id)} className={`relative rounded-md px-3 py-1.5 text-sm font-medium transition-colors ${ selected ? "text-zinc-950" : "text-zinc-500 hover:text-zinc-800" }`} > {selected && ( <motion.span layoutId={`${group}-indicator`} aria-hidden className="absolute inset-0 bg-white shadow-sm" style={{ borderRadius: 6 }} transition={reduced ? { duration: 0 } : spring} /> )} <span className="relative">{tab.label}</span> </button> ); })} </div> <AnimatePresence mode="wait" initial={false}> {tabs .filter((tab) => tab.id === active) .map((tab) => ( <motion.div key={tab.id} id={`${group}-panel-${tab.id}`} role="tabpanel" aria-labelledby={`${group}-tab-${tab.id}`} tabIndex={0} initial={{ opacity: 0, y: reduced ? 0 : 4 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, transition: { duration: 0.1 } }} transition={{ duration: 0.2, ease: [0.16, 1, 0.3, 1] }} className="mt-4" > {tab.content} </motion.div> ))} </AnimatePresence> </div> );}That is a pill indicator. For an underline, change the indicator's classes to absolute inset-x-0 -bottom-px h-0.5 bg-zinc-950 and drop the rounded-lg bg-zinc-100 p-1 from the list.
Let's go through the parts that matter.
The indicator sits under the label
The tab button is relative, the indicator is absolute inset-0, and the label is wrapped in its own relative span. Because the label comes later in the markup and is also positioned, it paints on top of the indicator. Without that wrapper, the white pill covers the text.
The layoutId is scoped with useId
layoutId is global to the page. If two tab groups both use layoutId="indicator", clicking a tab in one can send the indicator flying to the other. Prefixing it with useId() gives every instance its own name. Motion's <LayoutGroup id="..."> does the same job if you prefer to wrap the list.
The border radius is set in style
When Motion animates between two tabs of different widths, it scales the element with a transform. A scaled element would normally stretch its rounded corners into ovals. Motion corrects for this, but only for a borderRadius it can see in style or in animated values. If you set the radius only with a class, the corners can distort mid-slide. Setting style={{ borderRadius: 6 }} keeps them round.
The spring
stiffness: 520, damping: 38 is fast and has almost no overshoot. That suits an indicator: it is feedback for a click, so it should arrive quickly and stop cleanly. A soft or bouncy spring makes the interface feel slow, because the user has to wait for the pill to settle before the selection feels done.
A spring is also interruptible. If someone clicks three tabs quickly, the indicator changes course from wherever it is, instead of finishing one trip before starting the next.
The panels use a short tween with a strong ease-out curve instead. They fade in over 200ms and fade out in 100ms. Exits should be faster than entrances: the old content is no longer what the user wants, so get it out of the way.
Accessibility: roles, focus and keys
Tabs have a defined pattern in the WAI-ARIA Authoring Practices, and screen reader users expect it.
- The container has
role="tablist"and anaria-label. - Each tab has
role="tab",aria-selected, andaria-controlspointing at its panel. - Each panel has
role="tabpanel"andaria-labelledbypointing back at its tab. - Roving tabindex. Only the active tab has
tabIndex={0}; the others have-1. Pressing Tab moves focus into the tab list once and then out to the panel, instead of stopping on every tab. - Arrow keys move between tabs, wrapping at the ends, and Home and End jump to the first and last tab.
This component activates a tab as soon as it receives focus from the keyboard. That is the recommended behaviour when panels render instantly. If a panel is slow to load, move focus on arrow keys and activate on Enter or Space instead.
The indicator itself is decorative, so it is aria-hidden. The selected state lives on aria-selected, where assistive technology can read it.
Reduced motion
With "Reduce motion" turned on in the operating system, useReducedMotion() returns true and the indicator jumps to the new tab instead of sliding. The panel still fades, but without the 4px shift. Opacity changes are generally fine for people with motion sensitivity; movement across the screen is what causes discomfort.
Adding the indicator to shadcn/ui Tabs
The shadcn/ui Tabs component already handles roles, focus and keyboard support, so you only need the indicator. Control the value so you know which tab is active, render the indicator inside that trigger, and turn off the trigger's own active background.
"use client";import { motion, useReducedMotion } from "motion/react";import { useId, useState } from "react";import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";const sections = [ { value: "overview", label: "Overview" }, { value: "activity", label: "Activity" }, { value: "settings", label: "Settings" },];export function ProjectTabs() { const [tab, setTab] = useState("overview"); const reduced = useReducedMotion(); const group = useId(); return ( <Tabs value={tab} onValueChange={setTab}> <TabsList> {sections.map((s) => ( <TabsTrigger key={s.value} value={s.value} className="data-[state=active]:bg-transparent group-data-[variant=default]/tabs-list:data-[state=active]:shadow-none dark:data-[state=active]:border-transparent dark:data-[state=active]:bg-transparent" > {tab === s.value && ( <motion.span layoutId={`${group}-indicator`} aria-hidden className="absolute inset-0 bg-background shadow-sm" style={{ borderRadius: 6 }} transition={reduced ? { duration: 0 } : { type: "spring", stiffness: 520, damping: 38 }} /> )} <span className="relative">{s.label}</span> </TabsTrigger> ))} </TabsList> <TabsContent value="overview">Overview content</TabsContent> <TabsContent value="activity">Activity content</TabsContent> <TabsContent value="settings">Settings content</TabsContent> </Tabs> );}The override classes match the active-state classes in the current shadcn/ui Tabs (the new-york-v4 style), where the trigger is already relative. cn merges them over the defaults, so the trigger stops drawing its own background, shadow and dark-mode border. If your components/ui/tabs.tsx is older, check which classes it applies to the active trigger and override those. The rule is the same in every version: the trigger must not draw its own active highlight, or you'll see two.
The current shadcn/ui Tabs also has a line variant with an underline. That underline fades in under the new tab rather than sliding to it, so the same layoutId approach applies if you want the slide: render the underline as the indicator instead of the pill.
Common mistakes
- Two tab groups sharing a layoutId. Scope it with
useId()orLayoutGroup. - Changing font weight on the active tab. Bold text is wider, so the label widths change and the whole row shifts while the indicator slides. Change colour instead.
- Rendering the indicator in every tab and toggling opacity. Then there is nothing to animate between. Render it only in the active tab.
- Tabs inside a scrolling container. If the tab list scrolls sideways, add
layoutScrollto the scrolling element (make it amotion.div) so Motion accounts for the scroll offset. Otherwise the indicator can start its slide from the wrong place. - Panels that change height abruptly. If panels have different heights, the content below jumps. Either give the panel area a minimum height or animate its height.
- Forgetting the keyboard. A row of buttons that look like tabs is not a tab list until arrow keys and roving focus work.
For more components built on shadcn/ui with motion like this, see animated shadcn components.
FAQ
How do I animate the active tab indicator in React?
Render a motion.span with a layoutId inside the active tab only. When the active tab changes, Motion sees the same layoutId in a new place and animates the indicator from the old tab to the new one, with no measuring code.
Does shadcn/ui have animated tabs?
The shadcn/ui Tabs component has no sliding indicator by default. Its line variant fades the underline in rather than sliding it. You can add one by controlling the tab value and rendering a Motion layoutId element inside the active TabsTrigger, or install an animated tabs component through the shadcn CLI.
Why does my layoutId animation jump between components?
Every element with the same layoutId on the page counts as one element. If two tab groups use the same name, the indicator can move between them. Give each group a unique id with useId() or wrap each in a LayoutGroup.
Should tabs activate on focus or on click?
When panels render instantly, activating on focus with the arrow keys is recommended, because it saves a keypress. If a panel takes time to load, move focus with the arrow keys and activate on Enter or Space.
If you'd rather not build it
The Eased Tabs component is a free, MIT-licensed version of this pattern, built on Base UI's Tabs. It adds:
- an
underlineorpillvariant, horizontal or vertical - an indicator that keeps its corners round while it stretches between tabs of different widths, with no radius to set by hand
- panels that cross-fade 6px in the direction of travel while the panel area's height springs to fit
- a tab list that scrolls sideways with edge fades when tabs outgrow it, and routed tabs through
render={<Link href="..." />}
npx shadcn add https://eased.dev/r/tabs.jsonFor a view or filter switch where nothing else on the page changes, the Segmented control uses the same gliding indicator.