How to build animated pill tabs in React with Framer Motion
Animated pill tabs in React use Framer Motion's layoutId to share a single motion element across buttons, creating a smooth sliding indicator as the active tab changes. The content area swaps with AnimatePresence in 'wait' mode so the outgoing content exits before the new one enters.
- Stack: React 18 + Framer Motion 11 + Lucide React, ~240 lines, no extra dependencies.
- Active pill indicator: shared layoutId 'pill-indicator' with a spring (stiffness 400, damping 30).
- Content transition: AnimatePresence mode='wait' with y-axis slide (enter +16px, exit -10px, duration 350ms).
- Accessible: tabs are native <button> elements; keyboard navigation works out of the box.
- Responsive: two-column grid on md+ (text left, visual placeholder right), single-column on mobile.
Content Tabs Pill is a React section with a segmented pill tab bar that slides a shared indicator between options and fades the matching content panel into view. It covers the common 'one section, multiple audiences or features' pattern you see on SaaS pricing pages, feature overviews, and solution pages, without the heavy visual weight of underline tabs or full card switches.
Anatomy
The section has three stacked zones. At the top, a centered header with an optional badge, an h2 title and a subtitle paragraph, all revealed by a single whileInView fade-up. Below that, the pill tab bar: a rounded container with a border holds the buttons side by side; each button is relative-positioned so the indicator div can be absolutely inset. The active indicator is rendered inside the active button only, so Framer Motion's layout system can animate it across siblings. The content zone is a two-column grid on desktop: text (title, description, optional stats row) on the left, a visual placeholder on the right.
How it works
The pill indicator uses layoutId='pill-indicator' on a motion.div rendered conditionally inside each button. When the active tab changes, the old indicator unmounts and the new one mounts, Framer Motion detects the shared layoutId and animates the transition between the two positions using a spring (stiffness 400, damping 30). The indicator sits behind the label via z-index and carries 12% opacity on the accent color, so the text remains legible. Content swaps use AnimatePresence mode='wait': the exiting panel slides up and fades out before the entering panel slides in from below, driven by a custom cubic-bezier EASE constant [0.16, 1, 0.3, 1] for a snappy, natural feel.
How to build it in React
Set up state and data
Store the active tab id with useState, defaulting to the first tab's id. Keep the tab list as a prop so the component stays purely presentational. Each tab carries an id, label, optional Lucide icon name, title, description and an optional stats array.
const [activeTab, setActiveTab] = useState(tabs[0]?.id ?? ""); const current = tabs.find((t) => t.id === activeTab);Build the sliding pill bar
Render each tab as a native button inside a rounded flex container. Inside the active button, render a motion.div with layoutId='pill-indicator' positioned absolutely with inset:0. Framer Motion will animate it between whichever button becomes active, giving you the slide effect with zero manual calculations.
{isActive && ( <motion.div layoutId="pill-indicator" style={{ position: "absolute", inset: 0, borderRadius: "var(--radius-full)", background: "var(--color-accent)", opacity: 0.12 }} transition={{ type: "spring", stiffness: 400, damping: 30 }} /> )}Animate the content panel
Wrap the content block in AnimatePresence with mode='wait' and key it to current.id. This ensures the exiting content leaves fully before the entering content appears, preventing overlap. Use a y-offset fade: initial y:16, animate y:0, exit y:-10 with your EASE cubic-bezier for a polished directional feel.
const EASE = [0.16, 1, 0.3, 1] as const; <AnimatePresence mode="wait"> {current && ( <motion.div key={current.id} initial={{ opacity: 0, y: 16 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -10 }} transition={{ duration: 0.35, ease: EASE }} > {/* content */} </motion.div> )} </AnimatePresence>Add icons and optional stats
Resolve Lucide icons dynamically from the icon name string stored in each tab. Render stats as a flex row below the description when present. The stats use the accent color for the value and a muted color for the label, creating a quick visual anchor without a chart library.
function getIcon(name?: string) { if (!name) return null; return (LucideIcons as unknown as Record<string, React.ElementType>)[name] ?? null; }
When to use it
Use this pattern on feature overview sections, solution pages or pricing tiers where you have three to five distinct options to present and want to keep the vertical space compact. It works well right after a hero or a stats row. Skip it if you have more than six tabs, the pill bar wraps awkwardly on small screens, and avoid it when each panel's content is too similar; users will stop switching if they notice no real difference between panels.
Used by
- Stripe, Uses segmented pill tabs to switch between product categories on its main marketing pages.
- Notion, Pill-style tab bars appear on feature comparison and team solution sections of the marketing site.
- Linear, Animated tab switchers with sliding indicators present workflow features on the product landing page.
- Loom, Segmented controls switch between use-case panels on solution and pricing pages.
FAQ
Why use layoutId instead of animating left/width manually?
layoutId lets Framer Motion measure both the old and new button positions automatically and interpolate between them. Manual left/width animation requires you to track DOM measurements on every resize and tab change, that is fragile and unnecessary when the layout system handles it.
What happens if I have too many tabs?
The pill bar is a flex row with no wrap and no overflow scroll, so beyond five or six items the labels get cut off on mobile. For more options, switch to a vertical list or a select dropdown on small screens, or add overflow-x:auto with scroll-snap to the tab container.
Can I add a visual image or screenshot instead of the placeholder?
Yes. Replace the placeholder div in the right column with a Next.js <Image> or a <video> tag. The AnimatePresence key transition already animates the whole grid panel, so the image will fade and slide with the rest of the content at no extra cost.
Is the component keyboard accessible?
Each tab is a native <button>, so Tab and Shift+Tab cycle through them and Enter/Space activates the focused one. The component does not implement the ARIA tabs role pattern (roving tabindex with arrow keys), which is the full WAI-ARIA spec. If WCAG compliance is required, add role='tablist', role='tab', aria-selected and arrow-key handlers.