Tabs

Tabs organize content into multiple sections and allow users to navigate between them.

Overview
View your key metrics and recent project activity. Track progress across all your active projects.
You have 12 active projects and 3 pending tasks.
"use client"

import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "cn"
import {
  TabList as TabListPrimitive,
  TabPanel as TabPanelPrimitive,
  Tab as TabPrimitive,
  Tabs as TabsPrimitive,
} from "@animate-ui/react/primitives/react-aria"

import {
  Card,
  CardContent,
  CardDescription,
  CardHeader,
  CardTitle,
} from "@/components/ui/card"

function Tabs({
  className,
  ...props
}: React.ComponentProps<typeof TabsPrimitive>) {
  return (
    <TabsPrimitive
      data-slot="tabs"
      className={cn(
        "gap-2 group/tabs flex data-horizontal:flex-col",
        className
      )}
      {...props}
    />
  )
}

const tabsListVariants = cva(
  "rounded-lg p-[3px] group-data-horizontal/tabs:h-8 data-[variant=line]:rounded-none group/tabs-list inline-flex w-fit items-center justify-center text-muted-foreground group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col",
  {
    variants: {
      variant: {
        default: "bg-muted",
        line: "gap-1 bg-transparent",
      },
    },
    defaultVariants: {
      variant: "default",
    },
  }
)

function TabsList({
  className,
  variant = "default",
  ...props
}: React.ComponentProps<typeof TabListPrimitive> &
  VariantProps<typeof tabsListVariants>) {
  return (
    <TabListPrimitive
      data-slot="tabs-list"
      data-variant={variant}
      className={cn(tabsListVariants({ variant }), className)}
      {...props}
    />
  )
}

function TabsTrigger({
  className,
  ...props
}: React.ComponentProps<typeof TabPrimitive>) {
  return (
    <TabPrimitive
      data-slot="tabs-trigger"
      className={cn(
        "gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg:not([class*='size-'])]:size-4 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 group-data-[variant=default]/tabs-list:data-selected:shadow-sm group-data-[variant=line]/tabs-list:data-selected:shadow-none relative inline-flex h-[calc(100%-1px)] flex-1 cursor-default items-center justify-center whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 dark:text-muted-foreground dark:hover:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0",
        "group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-selected:bg-transparent dark:group-data-[variant=line]/tabs-list:data-selected:border-transparent dark:group-data-[variant=line]/tabs-list:data-selected:bg-transparent",
        "data-selected:bg-background data-selected:text-foreground dark:data-selected:border-input dark:data-selected:bg-input/30 dark:data-selected:text-foreground",
        "after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-selected:after:opacity-100",
        className
      )}
      {...props}
    />
  )
}

function TabsContent({
  className,
  ...props
}: React.ComponentProps<typeof TabPanelPrimitive>) {
  return (
    <TabPanelPrimitive
      data-slot="tabs-content"
      className={cn("text-sm flex-1 outline-none", className)}
      {...props}
    />
  )
}

export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }

export default function TabsDemo() {
  return (
    <Tabs defaultSelectedKey="overview" className="w-[400px]">
      <TabsList>
        <TabsTrigger id="overview">Overview</TabsTrigger>
        <TabsTrigger id="analytics">Analytics</TabsTrigger>
        <TabsTrigger id="reports">Reports</TabsTrigger>
        <TabsTrigger id="settings">Settings</TabsTrigger>
      </TabsList>
      <TabsContent id="overview">
        <Card>
          <CardHeader>
            <CardTitle>Overview</CardTitle>
            <CardDescription>
              View your key metrics and recent project activity. Track progress
              across all your active projects.
            </CardDescription>
          </CardHeader>
          <CardContent className="text-sm text-muted-foreground">
            You have 12 active projects and 3 pending tasks.
          </CardContent>
        </Card>
      </TabsContent>
      <TabsContent id="analytics">
        <Card>
          <CardHeader>
            <CardTitle>Analytics</CardTitle>
            <CardDescription>
              Track performance and user engagement metrics. Monitor trends and
              identify growth opportunities.
            </CardDescription>
          </CardHeader>
          <CardContent className="grid gap-2 text-sm">
            {[
              ["Page views", "+25%"],
              ["Visitors", "3,240"],
              ["Bounce rate", "41%"],
              ["Avg. session", "2m 14s"],
            ].map(([label, value]) => (
              <div key={label} className="flex items-center justify-between">
                <span className="text-muted-foreground">{label}</span>
                <span className="font-medium tabular-nums">{value}</span>
              </div>
            ))}
          </CardContent>
        </Card>
      </TabsContent>
      <TabsContent id="reports">
        <Card>
          <CardHeader>
            <CardTitle>Reports</CardTitle>
            <CardDescription>
              5 reports are ready to export.
            </CardDescription>
          </CardHeader>
        </Card>
      </TabsContent>
      <TabsContent id="settings">
        <Card>
          <CardHeader>
            <CardTitle>Settings</CardTitle>
            <CardDescription>
              Manage your account preferences and options. Customize your
              experience to fit your needs.
            </CardDescription>
          </CardHeader>
          <CardContent className="text-sm text-muted-foreground">
            Configure notifications, security, and themes.
          </CardContent>
        </Card>
      </TabsContent>
    </Tabs>
  )
}

Installation

Install the packages

npm install @animate-ui/react motion
pnpm add @animate-ui/react motion
yarn add @animate-ui/react motion
bun add @animate-ui/react motion

Add the shadcn/ui component

npx shadcn@latest add tabs
pnpm dlx shadcn@latest add tabs
yarn dlx shadcn@latest add tabs
bun x shadcn@latest add tabs

Animate it

The CLI finds your shadcn/ui components through components.json, swaps their primitive imports for Animate UI's and removes the CSS animation classes Motion replaces.

npx @animate-ui/cli@latest animate tabs
pnpm dlx @animate-ui/cli@latest animate tabs
yarn dlx @animate-ui/cli@latest animate tabs
bun x @animate-ui/cli@latest animate tabs

Usage

Import from Animate UI instead of react-aria-components: it re-exports all of React Aria, with animated TabList, Tab and TabPanel. Everything else stays the same.

import {
  Tab,
  TabList,
  TabPanel,
  Tabs,
} from '@animate-ui/react/primitives/react-aria';

export function Settings() {
  return (
    <Tabs>
      <TabList aria-label="Settings">
        <Tab id="account">Account</Tab>
        <Tab id="password">Password</Tab>
      </TabList>
      <TabPanel id="account">Make changes to your account.</TabPanel>
      <TabPanel id="password">Change your password.</TabPanel>
    </Tabs>
  );
}

Animation

  • One indicator slides to the new tab on a spring (stiffness 300, damping 30). Clicking quickly or holding an arrow key retargets it from where it is, at the speed it has. It moves on the compositor (a Web Animation of its translate, and of its size when the tabs' sizes differ), so the panels changing never hold it up. The TabList draws it as an indicator that copies the selected tab's own classes and data attributes, so it looks exactly like your selected tab (background, border, shadow, and the line variant's underline) in every style and theme, while the selected tab's own surface is hidden. Its focus ring stays on the tab.
  • The tabs' colors change over 200ms (ease, the curve of a color change), whatever transition your tabs set.
  • The panels change one after the other, like Motion's AnimatePresence with mode="wait": the outgoing panel fades out into a 5px blur over 150ms on an ease-out, staying in the page and out of reach (inert); once it's gone, the incoming one fades in from the blur over 300ms while its height glides from the outgoing panel's to its own, over 450ms on Motion's layout curve (a gentle start, then a long deceleration, never past its size). If the panel's content changes size on the way (content loaded late, a nested animation settling), the height follows it from where it is and at the speed it has, on a spring that never goes past (stiffness 660, damping 52), instead of ending on a stale height and jumping. The panels share an element (the tabs root, usually), where one shows at a time.
  • A panel's box stays on screen. When the panels hold a box (shadcn's Card: the panel itself if it draws a border, a background or a shadow, else the first element down its chain of single children that does), only what's inside it fades: the next panel's box takes over at the same height in the same frame, then glides to its own height, clipping its content but never its own border or corners, so the border never blinks or gets cut. Boxes that don't look the same (border, radius, background, shadow), or that hold text right inside, fade whole. Content positioned against something outside what fades (a step's badge on its rail) fades without the blur, so it stays in place.
  • The content slides by the tabs' order, like the menubar's and the navigation menu's content: going to a later tab, the leaving content slides 12px toward the start as it fades out and the next one comes in from 12px toward the end as it fades in (on the fades' own timings and curves), and the other way going back. Vertical tabs slide it vertically, right-to-left tabs mirror it. Only the content moves: inside a box (the Card), the box stays put and its content slides inside it, clipped by it; without one, the whole panel slides. Content coming in and turned back slides back out from where it is.
  • Changing tabs again before that's over stays clean: a panel passed over before it showed never shows, one coming in fades back out from where it is, one picked again while it leaves comes back from there, and the height goes on from where it is. Two panels never show at once.
  • Nothing animates on page load (nor a value restored before the page is painted).
  • Keyboard changes animate like clicks: arrow keys slide the indicator and change the panels (with keyboardActivation="manual", Enter selects).
  • React Aria keeps the outgoing panel mounted until its fade out is over, then removes it. With shouldForceMount, inactive panels stay in the DOM and inert, as in React Aria, and fade in when they become selected.
  • The className, style and children render props keep working. The indicator and the panel go through React Aria's render prop; a render of your own on TabList still receives the indicator, on TabPanel it replaces the animation.
  • <AnimateUIProvider tempo> paces every transition.
  • Under reduced motion, the indicator and the height jump; the panels still change one after the other, fading through their blur without sliding, the incoming one over 200ms.

API Reference

Every component takes exactly the props of React Aria's Tabs. Animate UI adds the animation props below.

Tabs, Tab

React Aria props only.

TabList

React Aria props, plus:

PropTypeDefault
transition?
Transition{ type: 'spring', stiffness: 300, damping: 30 }

TabPanel

React Aria props, plus:

PropTypeDefault
transition?
Transition{ duration: 0.3, ease: [0.25, 0.1, 0.35, 1], height: { duration: 0.45, ease: [0.4, 0, 0.1, 1] } }
exitTransition?
Transition{ duration: 0.15, ease: [0, 0, 0.58, 1] }

Built by Skyleen. The source code is available on GitHub.

Last updated: 10/18/2026

On this page