Animated Tabs with Framer Motion in Next.js App Router

Updated 8 min read

TL;DR To animate tabs with Framer Motion (now the motion package), render only the active tab's highlight as a motion element with a shared layoutId, and Motion slides it to whichever tab mounts it next. In the Next.js App Router, keep the bar in a layout, read the route with usePathname, and mark the active link with aria-current.

On this page
  1. What are we building?
  2. Is it Framer Motion or Motion now?
  3. Why should the tab bar live in a layout, not a page?
  4. The animated tab bar component
  5. How does layoutId make the pill slide?
  6. Why the label sits above the pill
  7. Matching nested routes
  8. Should a tab bar use role="tablist"?
  9. How do I respect reduced motion?
  10. Why does my tab pill jump instead of slide?
  11. Two tab bars on one page
  12. Testing it
  13. Variations worth trying
  14. FAQ
  15. How can I do a tab animation with Framer Motion?
  16. Does Framer Motion still exist?
  17. Why do shared layout animations break in the Next.js App Router?
  18. Should the active tab use aria-current or aria-selected?

Animated tabs with Framer Motion come down to one prop: give the active tab's highlight a layoutId, render it only under the active tab, and Motion animates it from the old tab to the new one. In the Next.js App Router, render the bar once in a layout.tsx, read the current route with usePathname(), make each tab a next/link, add aria-current="page" to the active link and wrap the bar in MotionConfig reducedMotion="user" so people who turn motion off get an instant switch.

Most animated tab demos you'll find switch panels with useState inside one page. That's fine for a settings panel, but a real app's tab bar changes the URL, has to survive the back button and a refresh, and has to stay put while pages change underneath it. This post covers that routed version: one client component of about 60 lines that I tested in a Next.js 15.5 app with React 19, Tailwind v4 and the motion package (14.4.0), plus the mistakes that make the pill jump instead of slide.

What are we building?

A floating bottom bar with four tabs (Home, Search, Inbox, Profile). Each tab is a real route. The active tab has a dark pill behind it, and when you navigate, the pill springs over to the new tab instead of jumping. It works with the browser's back button, deep links and refreshes, because the route is the source of truth, not component state.

This is the routing and accessibility side of a tab bar. If you want the visual side taken further (glass, a magnifying lens, a pill that stretches like a drop), the liquid glass tab bar is that version, and there are more in the animated navigation bars collection.

Liquid glass tab bar from qodesign with a glass drop sitting under the active tab

Liquid Glass Tab Bar running live. A glass drop magnifies the tab under it, stretches between tabs and bursts into the player.

Is it Framer Motion or Motion now?

Both. Framer Motion is now published as motion, and the React API is imported from motion/react (upgrade guide):

bash
npm i motion

The old framer-motion package still installs, but new code should import from motion/react. The layoutId API used below is the same in both.

Why should the tab bar live in a layout, not a page?

File structure:

src/app/animated-tab-bar-nextjs-app-router/
  layout.tsx
  TabBar.tsx
  page.tsx
  search/page.tsx
  inbox/page.tsx
  profile/page.tsx

The layout renders the page content and the bar:

tsx
import { TabBar } from "./TabBar";

export default function TabsLayout({ children }: { children: React.ReactNode }) {
  return (
    <>
      <main className="min-h-dvh px-6 pt-16 pb-28">{children}</main>
      <TabBar />
    </>
  );
}

This placement matters. The Next.js docs on layouts say that on navigation, layouts preserve state, stay interactive and don't rerender. The TabBar stays mounted while the pages below it change, so Motion has the previous pill position to animate from. If you rendered the bar inside each page.tsx, it would be a new instance on every route, and you'd be relying on animation state surviving a remount.

The layout itself stays a Server Component. Only TabBar needs "use client", because usePathname is a client hook (usePathname docs).

The animated tab bar component

tsx
"use client";

import Link from "next/link";
import { usePathname } from "next/navigation";
import { MotionConfig, motion } from "motion/react";

const BASE = "/animated-tab-bar-nextjs-app-router";

const tabs = [
  { href: BASE, label: "Home" },
  { href: `${BASE}/search`, label: "Search" },
  { href: `${BASE}/inbox`, label: "Inbox" },
  { href: `${BASE}/profile`, label: "Profile" },
];

function isActive(pathname: string, href: string) {
  if (href === BASE) return pathname === BASE;
  return pathname === href || pathname.startsWith(`${href}/`);
}

export function TabBar() {
  const pathname = usePathname();

  return (
    <MotionConfig reducedMotion="user">
      <nav
        aria-label="Main"
        className="fixed inset-x-0 bottom-4 mx-auto w-fit rounded-full border border-black/10 bg-white/80 p-1 shadow-lg backdrop-blur"
      >
        <ul className="flex gap-1">
          {tabs.map((tab) => {
            const active = isActive(pathname, tab.href);
            return (
              <li key={tab.href} className="relative">
                {active && (
                  <motion.span
                    layoutId="tab-pill"
                    className="absolute inset-0 rounded-full bg-neutral-900"
                    transition={{ type: "spring", stiffness: 500, damping: 38 }}
                  />
                )}
                <Link
                  href={tab.href}
                  aria-current={active ? "page" : undefined}
                  className={`relative z-10 block rounded-full px-4 py-2 text-sm font-medium transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-900 ${
                    active ? "text-white" : "text-neutral-600 hover:text-neutral-900"
                  }`}
                >
                  {tab.label}
                </Link>
              </li>
            );
          })}
        </ul>
      </nav>
    </MotionConfig>
  );
}

Now the parts worth explaining.

How does layoutId make the pill slide?

Only the active tab renders the motion.span. When you click Search, React removes the span from under Home and adds a new one under Search. Both have layoutId="tab-pill", so Motion treats them as the same element in two places and animates position and size from the old box to the new one (layout animations docs).

You don't measure anything. No getBoundingClientRect, no left and width in state, no resize listener. Tabs can have different label widths and the pill still fits each one, because it's absolute inset-0 inside its own li.

The spring (stiffness: 500, damping: 38) settles fast with almost no overshoot. For a bouncier feel, lower the damping to around 25. I'd keep it tight on navigation, since people tap tabs quickly and a wobbly pill gets annoying by the third tap.

Why the label sits above the pill

The pill is absolutely positioned inside the li, and the link has relative z-10. Without the z-10, the pill paints over the text during the animation. The text color switches with a normal CSS transition-colors, so it fades while the pill moves under it.

Matching nested routes

pathname === href is enough for flat routes, but Inbox probably has /inbox/[id] pages, and the Inbox tab should stay active there. isActive checks for the tab's path followed by a slash, so /inbox/42 matches Inbox and /inboxes doesn't. The root tab gets an exact match only, otherwise Home would be active on every route.

Should a tab bar use role="tablist"?

A tab bar that navigates between pages is navigation, so it's built from links inside a nav, not from the ARIA tablist pattern. role="tablist" is for switching panels inside one page, and it brings arrow-key behavior that screen reader users won't expect from site navigation.

What the component does:

  • nav aria-label="Main" creates a navigation landmark with a name, so screen reader users can jump to it.
  • aria-current="page" on the active link tells assistive tech which page you're on (aria-current on MDN). The pill is decoration, and the attribute carries the meaning.
  • focus-visible:outline-2 gives keyboard users a visible focus ring, and the plain mouse click doesn't show one.
  • Real Link elements mean middle-click, "open in new tab" and prefetching all work.

On a phone, check the tap target size. The tabs here are about 36px tall. If your bar is the main navigation on mobile, bump the padding toward 44px.

How do I respect reduced motion?

<MotionConfig reducedMotion="user"> makes every Motion component inside it follow the operating system's "reduce motion" setting. According to the MotionConfig docs, when reduced motion is on, transform and layout animations are turned off while opacity and color animations still run. For this tab bar that's the right result: the pill appears under the new tab right away, and the label color still changes.

If you already have a MotionConfig higher up in your app, you can drop this one.

Why does my tab pill jump instead of slide?

This is the question I see most in issues and forum threads about shared layout animations in the App Router. In my testing it's almost always one of these:

SymptomCauseFix
Pill teleports on every clickThe bar is rendered inside each page.tsx, so it remountsMove it to layout.tsx
Pill teleports, bar is in a layoutYou used template.tsx, which remounts on navigation by designUse layout.tsx for the bar
Pill flies between two different barsTwo bars on one page share the same layoutIdWrap each bar in its own LayoutGroup id
Pill covers the label mid-animationThe label has no stacking contextAdd relative z-10 to the label
Pill animates on first loadThe active state is computed after mount (for example in useEffect)Derive it from usePathname() during render

The template.tsx one catches people because the file looks like a layout. The Next.js docs say templates are given a unique key, so the Client Components inside them reset on navigation. That reset is exactly what breaks a shared layout animation.

Two tab bars on one page

layoutId is global inside a Motion tree. If a page has a sort control and a view control that both use layoutId="segment-pill", clicking one can make the pill fly across the page into the other. LayoutGroup with an id namespaces the ids inside it (LayoutGroup docs). These two segmented controls switch state inside the page, so they are buttons with aria-pressed, not links:

tsx
"use client";

import { useState } from "react";
import { LayoutGroup, motion } from "motion/react";

function Segmented({ label, options }: { label: string; options: string[] }) {
  const [value, setValue] = useState(options[0]);

  return (
    <div role="group" aria-label={label} className="inline-flex rounded-full bg-neutral-100 p-1">
      {options.map((option) => (
        <button
          key={option}
          type="button"
          aria-pressed={value === option}
          onClick={() => setValue(option)}
          className="relative rounded-full px-3 py-1.5 text-sm font-medium"
        >
          {value === option && (
            <motion.span
              layoutId="segment-pill"
              className="absolute inset-0 rounded-full bg-white shadow"
              transition={{ type: "spring", stiffness: 500, damping: 38 }}
            />
          )}
          <span className="relative z-10">{option}</span>
        </button>
      ))}
    </div>
  );
}

export function Toolbar() {
  return (
    <div className="flex flex-wrap gap-4">
      <LayoutGroup id="sort">
        <Segmented label="Sort" options={["New", "Top", "Hot"]} />
      </LayoutGroup>
      <LayoutGroup id="view">
        <Segmented label="View" options={["Grid", "List"]} />
      </LayoutGroup>
    </div>
  );
}

Both controls reuse the same component and the same layoutId, and each pill stays inside its own group.

Testing it

I built the four routes and ran:

bash
npx tsc --noEmit
npx next build

Both passed, and all four tab routes prerendered as static pages. The Toolbar above went through the same tsc and next build run. The bar adds no data fetching, so each route can stay static.

Things to check by hand in the browser:

  1. Click each tab and watch the pill travel, including from the first tab straight to the last.
  2. Use the back button. The pill should animate back, since usePathname updates on every navigation.
  3. Reload on /search. The pill should start under Search with no animation.
  4. Turn on reduced motion (macOS: Accessibility, Display, Reduce motion) and click around. The pill should jump.
  5. Tab through with the keyboard and make sure the focus ring is visible on the dark pill too.

Variations worth trying

  • Segmented control at the top of a page. Same code, different classes: drop fixed and bottom-4, and use a lighter pill color. If the segments switch content inside one page instead of routes, swap the links for buttons with useState and use the tablist pattern.
  • Underline instead of a pill. Change the span to absolute inset-x-2 bottom-0 h-0.5. The layoutId trick works the same way.
  • Icons. Put an icon above the label inside the Link. Keep the text label visible, or at least as aria-label, because icon-only tabs are harder to recognize.

If you want a working piece to pull apart, the /free page gives you one component with the full source, and the free registry on GitHub starts every component with "use client" and checks useReducedMotion() the way this bar uses MotionConfig.

I make animated React components at qodesign, and this pill is the starting point for most of the navigation pieces I build.

FAQ

How can I do a tab animation with Framer Motion?

Render a highlight element only inside the active tab and give it a layoutId. When the active tab changes, Motion sees the same layoutId unmount in one place and mount in another and animates between the two boxes, so you never measure positions yourself.

Does Framer Motion still exist?

Yes, under a new name. The library is now published as motion and the React API is imported from motion/react. The old framer-motion package still works, and the layoutId and LayoutGroup APIs are the same in both.

Why do shared layout animations break in the Next.js App Router?

Usually because the animated element remounts on navigation. Put the tab bar in a layout.tsx, which persists across routes, not in a page or a template.tsx, which create a new instance each time.

Should the active tab use aria-current or aria-selected?

For a bar that links to different pages, use aria-current="page" on the active link inside a nav. aria-selected belongs to the tablist pattern, which is for switching panels inside a single page.