# Animated Tabs with Framer Motion in Next.js App Router

> 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.

- URL: https://qodesign.dev/blog/animated-tab-bar-nextjs-app-router
- Author: Teodor Avadani (https://qodesign.dev/blog/author/teodor-avadani)
- Published: 2026-10-03
- Updated: 2026-10-20
- Category: Animation
- Publisher: qodesign (https://qodesign.dev), a shop of animated React + Tailwind CSS components for Next.js, each sold with its full source code and an AI prompt.

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](/components/tabbar-liquid) is that version, and there are more in the [animated navigation bars](/ui/animated-navigation-bars) collection.

![Liquid glass tab bar from qodesign with a glass drop sitting under the active tab](/previews/tabbar-liquid.jpg)

## 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](https://motion.dev/docs/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:

```txt
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](https://nextjs.org/docs/app/getting-started/layouts-and-pages) 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](https://nextjs.org/docs/app/api-reference/functions/use-pathname)).

## 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](https://motion.dev/docs/react-layout-animations)).

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](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-current)). 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](https://motion.dev/docs/react-motion-config), 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:

| Symptom | Cause | Fix |
| --- | --- | --- |
| Pill teleports on every click | The bar is rendered inside each `page.tsx`, so it remounts | Move it to `layout.tsx` |
| Pill teleports, bar is in a layout | You used `template.tsx`, which remounts on navigation by design | Use `layout.tsx` for the bar |
| Pill flies between two different bars | Two bars on one page share the same `layoutId` | Wrap each bar in its own `LayoutGroup id` |
| Pill covers the label mid-animation | The label has no stacking context | Add `relative z-10` to the label |
| Pill animates on first load | The 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](https://nextjs.org/docs/app/api-reference/file-conventions/template) 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](https://motion.dev/docs/react-layout-group)). 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](/free) page gives you one component with the full source, and the free registry on [GitHub](https://github.com/teodoravadani/qodesign-free) 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.
