Guide

How to build animated tabs with a sliding indicator in Framer Motion

A sliding tab indicator is one of the most common Framer Motion patterns, and it takes surprisingly little code once you know about layoutId. This guide builds the technique from scratch, explains the details that make it feel right, and then shows the beUI Tabs component, which packages the same idea with overflow scrolling, icons and three visual styles.

How does a layoutId tab indicator work?

Render the indicator only inside the selected tab and give it the same layoutId everywhere. When the selection changes, the old indicator unmounts and a new one mounts inside a different button. Because both share a layoutId, Framer Motion treats them as one element: it measures where the new box is, starts it at the old box's position and size, and animates the difference with transforms.

That means you never calculate offsets or widths yourself. The indicator can be absolutely positioned to fill its button, and tabs of any width work, because the animation is measured from the real layout on every change.

A sliding tabs component from scratch

The snippet below is a complete, working version. The indicator sits behind the label, which gets relative z-10 so the text stays on top. A spring transition gives the slide a natural settle.

  • Use a unique layoutId per tab bar (useId works). Two bars sharing a string id would pass one indicator between them.
  • Put borderRadius in style, not only in a class. Framer Motion animates layout with scale, and it can only counter-correct corner radius values it knows about.
  • Pick a spring that settles without much overshoot. A bouncy indicator in a scrollable list can briefly push past the edge and flash a scrollbar.
TSXsliding-tabs.tsx
"use client";

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

const tabs = ["Overview", "Activity", "Settings"];

export function SlidingTabs() {
  const [active, setActive] = useState(tabs[0]);
  // A unique id per instance, so two tab bars on one page never share an indicator.
  const indicatorId = useId();

  return (
    <div role="tablist" className="inline-flex gap-1 rounded-full bg-neutral-100 p-1">
      {tabs.map((tab) => (
        <button
          key={tab}
          type="button"
          role="tab"
          aria-selected={active === tab}
          onClick={() => setActive(tab)}
          className="relative rounded-full px-3.5 py-1.5 text-sm font-medium"
        >
          {active === tab ? (
            <motion.span
              layoutId={indicatorId}
              // Set the radius in style so Motion can correct it while the box scales.
              style={{ borderRadius: 9999 }}
              transition={{ type: "spring", stiffness: 400, damping: 36 }}
              className="absolute inset-0 bg-white shadow-sm"
            />
          ) : null}
          <span className="relative z-10">{tab}</span>
        </button>
      ))}
    </div>
  );
}

Install the beUI Tabs component

If you want the pattern ready-made, beUI ships Tabs as copy-paste source through the shadcn CLI. The command adds the file to components/motion/tabs.tsx along with the small helpers it imports, so you own and can edit the code.

ShellTerminal
npx shadcn@latest add @beui/tabs

Using Tabs, TabsList, TabsTrigger and TabsContent

The API is composable: Tabs holds the value, TabsList renders the tablist, TabsTrigger renders each tab and TabsContent shows the matching panel. The variant prop switches between pill, segment and underline styles, and icons="selected" springs a trigger's icon open only on the selected tab.

For controlled state, pass value and onValueChange instead of defaultValue. When the triggers do not fit, TabsList fades the edges and adds scroll buttons, and it scrolls the selected tab into view.

TSXdashboard-tabs.tsx
import { BarChart3, Bell, Home } from "lucide-react";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/motion/tabs";

export function DashboardTabs() {
  return (
    <Tabs defaultValue="home" variant="pill" icons="selected">
      <TabsList>
        <TabsTrigger value="home" icon={<Home className="size-3.5" />}>Home</TabsTrigger>
        <TabsTrigger value="stats" icon={<BarChart3 className="size-3.5" />}>Stats</TabsTrigger>
        <TabsTrigger value="alerts" icon={<Bell className="size-3.5" />}>Alerts</TabsTrigger>
      </TabsList>
      <TabsContent value="home">Your summary.</TabsContent>
      <TabsContent value="stats">Charts and numbers.</TabsContent>
      <TabsContent value="alerts">Recent notifications.</TabsContent>
    </Tabs>
  );
}

Controlled tabs for filters and date ranges

Segment tabs work well as a compact switch for a chart range or a list filter. Keep the value in your own state and read it wherever you fetch or filter data. TabsTrigger also accepts indicatorClassName if you want a different indicator color for one bar.

TSXrange-tabs.tsx
"use client";

import { useState } from "react";
import { Tabs, TabsList, TabsTrigger } from "@/components/motion/tabs";

export function RangeTabs() {
  const [range, setRange] = useState("week");

  return (
    <Tabs value={range} onValueChange={setRange} variant="segment">
      <TabsList>
        <TabsTrigger value="day">Day</TabsTrigger>
        <TabsTrigger value="week">Week</TabsTrigger>
        <TabsTrigger value="month">Month</TabsTrigger>
      </TabsList>
    </Tabs>
  );
}

Accessibility and reduced motion for animated tabs

Each beUI trigger is a native button with role tab and aria-selected, so it is focusable and clickable with the keyboard by default. Inactive TabsContent panels stay in the DOM with the hidden attribute, which keeps their text in server-rendered HTML without exposing it to screen readers. Arrow keys move between enabled tabs and Home and End jump to the ends, and each tab points at its panel with aria-controls while the panel has the tabpanel role labelled by its tab, which covers the WAI-ARIA tabs pattern.

When the user has reduced motion enabled, Tabs sets its transitions to zero duration through MotionConfig: the indicator jumps to the new tab instead of sliding, and panels fade in without the small vertical offset. In your own from-scratch version, you can get similar behavior by wrapping the app in MotionConfig with reducedMotion="user", or by reading useReducedMotion and swapping the transition.

Components in this guide

FAQ

What is layoutId in Framer Motion?
layoutId links two motion elements across renders. When one with a given layoutId unmounts and another with the same id mounts, Framer Motion animates from the old element's position and size to the new one, which is exactly what a sliding tab indicator needs.
Why does my tab indicator stretch or have squashed corners?
Layout animations use scale transforms. Set borderRadius in the style prop so Framer Motion can correct it during the animation, and keep text out of the scaled element; put the label in a sibling span above the indicator.
Why does the indicator jump between two tab bars on the same page?
They share the same layoutId string. Give each bar its own id, for example from useId, or wrap each bar in its own LayoutGroup with a distinct id.
Do I need the motion package or framer-motion?
beUI imports from motion/react, the current package name for Framer Motion. The shadcn CLI installs it for you when you add the Tabs component.
Is the beUI Tabs component free?
Yes. beUI is free and open source under the MIT license, and you install the source into your own project, so you can change it however you like.

Updated