Tab components are everywhere: dashboards, settings panels, product pages. But most implementations are static, lifeless, and forgettable. What if your tabs felt alive, with smooth spring animations, a stacked card effect on hover, and a polished active indicator that glides between buttons?
A basic tab switcher can show and hide content. A better one gives users a clear active state, smooth transitions, and a little bit of motion that makes the interface feel alive. That's the idea behind this component: a reusable animated tab system built in the Shadcn style, with React, Tailwind CSS, and Motion.
In this tutorial, you’ll build exactly that: a fully animated tab component built by Shadcn/ui, Framer Motion, and a ready-to-use registry component from Shadcn Space.
By the end, you’ll have a reusable <Tabs/> component with:
A spring-animated active pill indicator
A stacked card effect that fans out on hover
A smooth entrance animation when the active tab changes
Fully theme-aware styling using Shadcn/ui CSS variables
Video walkthrough: If you prefer to follow along visually, watch the full tutorial on YouTube:
Table of Contents
Prerequisites
Before you begin, make sure you have a working knowledge of:
React and TypeScript basics
Tailwind CSS utility classes
The basics of Shadcn/ui (component installation and theming)
You’ll also need a Next.js or Vite project with the following already set up:
Shadcn/ui installed and initialized
Framer Motion (also referred to as motion/react) installed
What You’ll Build
Here’s an overview of the component architecture you’ll create in this tutorial:
AnimatedTabMotion (page/demo entry point)
└── Tabs (tab bar + content orchestrator)
├── Tab buttons (with spring-animated active pill)
└── FadeInStack (stacked, animated content panels)
The key behaviors are:
Spring pill animation – A spring pill animation is a UI effect in which the active tab indicator, a rounded, pill-shaped highlight, physically moves from one button to another using a spring physics curve rather than a standard CSS transition. Instead of teleporting or fading, the pill slides between tabs with a subtle bounce at the end, mimicking the momentum of a real physical object.
Stacked card effect – inactive tab panels are rendered behind the active one, scaled down and slightly faded, giving a layered depth illusion.
Fan-out on hover – when the user hovers over the content area, the stacked cards spread out vertically.
Bounce entrance – the top (active) card animates downward and back into place when a new tab is selected.
Install the Component via Shadcn Space CLI
Shadcn Space is a registry of production-ready Shadcn/ui-compatible components. Instead of scaffolding this component from scratch, you can pull it directly into your project using the Shadcn CLI.
Check out their Getting Started guide to learn how to use the Shadcn CLI with third-party registries.
Run one of the following commands, depending on your package manager:
pnpm
pnpm dlx shadcn@latest add @shadcn-space/tabs-01
npm
npx shadcn@latest add @shadcn-space/tabs-01
Yarn
yarn dlx shadcn@latest add @shadcn-space/tabs-01
Bun
bunx --bun shadcn@latest add @shadcn-space/tabs-01
This scaffolds the component file into your project, pre-wired to your existing Shadcn/ui theme tokens. You can then customize or extend it as needed, which is exactly what you’ll learn in this tutorial.
Understand the Component Structure
Before writing any code, let’s review the full component and break it into logical pieces. Here is the complete implementation:
"use client";
import { useState } from "react";
import { motion } from "motion/react";
import { cn } from "@/lib/utils";
type Tab = {
title: string;
value: string;
content?: React.ReactNode;
};
type TabsProps = {
tabs: Tab[];
containerClassName?: string;
activeTabClassName?: string;
tabClassName?: string;
contentClassName?: string;
};
const tabs = [
{
title: "Product",
value: "product",
content: (
<div className="w-full overflow-hidden relative rounded-2xl p-10 text-xl md:text-4xl font-bold text-foreground bg-muted h-[300px] border border-border">
<p>Product Tab</p>
</div>
),
},
{title: "Services",
value: "services",
content: (
<div className="w-full overflow-hidden relative rounded-2xl p-10 text-xl md:text-4xl font-bold text-foreground bg-muted h-[300px] border border-border">
<p>Services tab</p>
</div>
),
},
{
title: "Playground",
value: "playground",
content: (
<div className="w-full overflow-hidden relative rounded-2xl p-10 text-xl md:text-4xl font-bold text-foreground bg-muted h-[300px] border border-border">
<p>Playground tab</p>
</div>
),
},
{
title: "Content",
value: "content",
content: (
<div className="w-full overflow-hidden relative rounded-2xl p-10 text-xl md:text-4xl font-bold text-foreground bg-muted h-[300px] border border-border">
<p>Content tab</p>
</div>
),
},
{
title: "Random",
value: "random",
content: (
<div className="w-full overflow-hidden relative rounded-2xl p-10 text-xl md:text-4xl font-bold text-foreground bg-muted h-[300px] border border-border">
<p>Random tab</p>
</div>
),
},
];
const Tabs = ({
tabs,
containerClassName,
activeTabClassName,
tabClassName,
contentClassName,
}: TabsProps) => {
const [activeIdx, setActiveIdx] = useState(0);
const [hovering, setHovering] = useState(false);
const handleSelect = (idx: number) => {
setActiveIdx(idx);
};
const reorderedTabs = [
tabs[activeIdx],
...tabs.filter((_, i) => i !== activeIdx),
];
return (
<>
<div
className={cn(
"flex flex-row items-center justify-start [perspective:1000px] relative overflow-auto sm:overflow-visible no-visible-scrollbar max-w-full w-full",
containerClassName,
)}
>
{tabs.map((tab, idx) => {
const isActive = idx === activeIdx;
return (
<button
key={tab.value}
onClick={() => handleSelect(idx)}
onMouseEnter={() => setHovering(true)}
onMouseLeave={() => setHovering(false)}
className={cn("relative px-4 py-2 rounded-full", tabClassName)}
style={{ transformStyle: "preserve-3d" }}
>
{isActive && (
<motion.div
layoutId="clickedbutton"
transition={{ type: "spring", bounce: 0.3, duration: 0.6 }}
className={cn(
"absolute inset-0 bg-primary rounded-full",
activeTabClassName,
)}
/>
)}
<span
className={cn(
"relative block text-sm",
isActive ? "text-background": "text-foreground",
)}
>
{tab.title}
</span>
</button>
);
})}
</div>
<FadeInStack
tabs={reorderedTabs}
hovering={hovering}
className={cn("mt-10", contentClassName)}
/>
</>
);
};
type FadeInStackProps = {
className?: string;
tabs: Tab[];
hovering?: boolean;
};
const FadeInStack = ({ className, tabs, hovering }: FadeInStackProps) => {
return (
<div className="relative w-full h-[300px]">
{tabs.map((tab, idx) => (
<motion.div
key={tab.value}
layoutId={tab.value}
style={{
scale: 1 - idx * 0.1,
top: hovering ? idx * -15 : 0,
zIndex: -idx,
opacity: idx < 3 ? 1 - idx * 0.1 : 0,
}}
animate={{
y: idx === 0 ? [0, 40, 0] : 0,
}}
className={cn("w-full h-full absolute top-0 left-0", className)}
>
{tab.content}
</motion.div>
))}
</div>
);
};
export default function AnimatedTabMotion() {
return (
<>
<div className="[perspective:1000px] relative flex flex-col max-w-5xl mx-auto w-full items-start justify-start mb-13">
<Tabs tabs={tabs} />
</div>
</>
);
}
Now, let’s break this down piece by piece.
Step 1: Define the Tab Data Types
type Tab = {
title: string;
value: string;
content?: React.ReactNode;
};
type TabsProps = {
tabs: Tab[];
containerClassName?: string;
activeTabClassName?: string;
tabClassName?: string;
contentClassName?: string;
};
The Tab type defines the shape of each tab item:
title– the label rendered in the tab button.value– a unique key used to identify each tab (and as the Framer MotionlayoutId).content– an optionalReact.ReactNode, meaning you can pass any JSX as the panel body.
The TabsProps type makes the Tabs component highly composable. Every visual layer has an override className, so you can restyle the active pill, individual tab buttons, and the content area independently without touching the core logic.
Step 2: Build the Tab Data Array
const tabs = [
{
title: “Product”,
value: “product”,
content: (
Product Tab
), }, // ... more tabs ];
Each tab’s content is a JSX element styled with Shadcn/ui semantic tokens like bg-muted, text-foreground and border-border. This is intentional: these tokens automatically adapt to your light/dark theme without any extra configuration.
You can replace these placeholder <div> panels with any real content: charts, forms, tables, media, whatever your use case demands.
Step 3: Build the Tabs Component (Tab Bar + State)
const [activeIdx, setActiveIdx] = useState(0);
const [hovering, setHovering] = useState(false);
Two pieces of state drive the entire component:
activeIdxtracks which tab is currently selected (by array index).hoveringtracks whether the user’s cursor is over any tab button, which is passed toFadeInStackto trigger the fan-out effect.
Reorder Tabs for the Stack Effect
const reorderedTabs = [
tabs[activeIdx],
…tabs.filter((_, i) => i !== activeIdx),
];
This is one of the most clever aspects of the architecture. Instead of showing only the active tab’s content, you always render all tab panels – but you put the active one first in the array. This is what enables the stacked-cards visual:
Index 0 = the active panel, rendered on top with full scale and opacity.
Index 1, 2 = the next panels, stacked behind with reduced scale and opacity.
Index 3+ = hidden (opacity 0).
Render the Tab Buttons with a Spring Pill
{tabs.map((tab, idx) => {
const isActive = idx === activeIdx;
return (
<button
key={tab.value}
onClick={() => handleSelect(idx)}
onMouseEnter={() => setHovering(true)}
onMouseLeave={() => setHovering(false)}
className={cn(“relative px-4 py-2 rounded-full”, tabClassName)}
style={{ transformStyle: “preserve-3d” }}
>
{isActive && (
<motion.div
layoutId=“clickedbutton”
transition={{ type: “spring”, bounce: 0.3, duration: 0.6 }}
className={cn(
“absolute inset-0 bg-primary rounded-full”,
activeTabClassName,
)}
/>
)}
<span
className={cn(
“relative block text-sm”,
isActive ? “text-background” : “text-foreground”,
)}
>
{tab.title}
</span>
</button>
);
})}
The magic here is layoutId=“clickedbutton” on the motion.div. When only one element with a given layoutId is mounted at a time, Framer Motion tracks its position in the DOM. When it unmounts from one button and mounts onto another, Framer Motion automatically animates the transition is between the two DOM positions. This creates the sliding pill effect with zero manual calculation.
The transition config uses a spring with bounce: 0.3 a duration: 0.6, giving it a natural, slightly elastic feel rather than a mechanical linear slide.
The transformStyle: “preserve-3d” on the button enables 3D CSS transforms, which pair with the [perspective:1000px] on the container for a subtle depth effect.
Step 4: Build the FadeInStack Component
const FadeInStack = ({ className, tabs, hovering }: FadeInStackProps) => {
return (
<div className="relative w-full h-[300px]">
{tabs.map((tab, idx) => (
<motion.div
key={tab.value}
layoutId={tab.value}
style={{
scale: 1 - idx * 0.1,
top: hovering ? idx * -15 : 0,
zIndex: -idx,
opacity: idx < 3 ? 1 - idx * 0.1 : 0,
}}
animate={{
y: idx === 0 ? [0, 40, 0] : 0,
}}
className={cn("w-full h-full absolute top-0 left-0", className)}
>
{tab.content}
</motion.div>
))}
</div>
);
};
Let’s unpack the visual logic for each motion.div:
scale: 1 - idx * 0.1
Each card behind the active one is scaled down by 10% per layer. So:
Active card (idx 0):
scale: 1.0Second card (idx 1):
scale: 0.9Third card (idx 2):
scale: 0.8
This creates clear depth separation between the stacked layers.
top: hovering ? idx * -15 : 0
When hovering is true, each card shifts upward by idx * 15px. The active card doesn’t move (idx 15 = 0), but the cards behind it fan out at -15px, -30px, and so on. This gives a satisfying “deck spreading” effect on hover.
zIndex: -idx
Negative z-index stacks cards in order: the active card sits on top (z-index 0), while subsequent cards descend further behind.
opacity: idx < 3 ? 1 - idx * 0.1 : 0
Cards at index 3 and beyond are hidden entirely. The first three cards fade progressively: 1.0, 0.9, 0.8.
animate={{ y: idx === 0 ? [0, 40, 0] : 0 }}
Only the active card (idx 0) gets this keyframe animation. When a tab is selected, and the reorderedTabs array is rebuilt, the new active card enters via a downward dip (y: 40) and bounces back to its rest position. This is a quick, tactile confirmation that the tab has changed.
layoutId={tab.value}
Each card also has a layoutId matching one value. When reorderedTabs is recomputed, and array positions shift, Framer Motion can track each card’s identity and animate it smoothly between positions, preventing jarring jumps.
Step 5: Compose the Page Component
export default function AnimatedTabMotion() {
return (
<div className="[perspective:1000px] relative flex flex-col max-w-5xl mx-auto w-full items-start justify-start mb-13">
<Tabs tabs={tabs} />
</div>
);
}
The outer wrapper applies [perspective:1000px] – a Tailwind arbitrary property that sets the CSS perspective value. This is what gives the 3D depth to the transformStyle: “preserve-3d” on the tab buttons.
The max-w-5xl and mx-auto center the component on wide screens while items-start left-aligns the tab bar, which matches most real-world UI patterns.
Step 6: Customize the Component
Because Tabs accepts class-name overrides for every visual layer, so you can fully restyle the component to match your design system. Here’s an example with a darker active pill and a tighter layout:
<Tabs
tabs={tabs}
containerClassName="gap-1"
tabClassName="text-xs px-3 py-1.5"
activeTabClassName="bg-zinc-900 dark:bg-white"
contentClassName="mt-6"
/>
You can also replace the placeholder content panels with real content. Here’s an example using a card with a real description:
const tabs = [
{
title: "Overview",
value: "overview",
content: (
<div className="w-full rounded-2xl p-8 bg-muted border border-border h-[300px] flex flex-col gap-4">
<h2 className="text-2xl font-bold text-foreground">Product Overview</h2>
<p className="text-muted-foreground text-sm leading-relaxed">
Our platform helps teams ship faster with a fully integrated design-to-code workflow.
</p>
</div>
),
},
// ...
];
Live Preview
Key Concepts Recap
Here’s a summary of the core Framer Motion techniques used in this component:
Technique | What it does |
| Animates a shared element between DOM positions (the sliding pill) |
| Tracks card identity during re-ordering, so Framer Motion animates position changes |
| Keyframe animation for the bounce entrance on tab change |
| Inline reactive styles that create the stacked-card depth effect |
| Applies a physics-based spring curve instead of a CSS easing function |
Conclusion
In this tutorial, you built a fully animated, theme-aware tab component using Shadcn/ui and Framer Motion. You learned how to:
Use
layoutIdto create a spring-animated sliding pill indicatorRender all tab panels simultaneously and reorder them to create a stacked card effect
Drive hover and depth effects with inline reactive
stylepropsApply Framer Motion keyframe animations for a tactile bounce entrance
Keep the component fully customizable via class name overrides
This pattern, combining Shadcn/ui’s semantic design tokens with Framer Motion’s layout animations, scales well beyond tabs. You can apply the same layoutId and stack reorder technique to carousels, image galleries, notification toasts, and more.
You can explore the full component and more animated UI blocks at Shadcn Space, where the CLI command makes it trivial to drop production-quality components directly into your project.
Resources
I wrote this article with the help of Mihir Koshti (Sr. Full Stack Developer) – Connect on LinkedIn.