How to Build an Animated Shadcn Tab Component with Shadcn/ui
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 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: Prerequisites What You’ll Build Install the Component via Shadcn Space CLI Understand the Component Structure Step 1 - Define the Tab Data Types Step 2 - Build the Tab Data Array Step 3 - Build the Tabs Component (Tab Bar + State) Step 4 - Build the FadeInStack Component Step 5 - Compose the Page Component Step 6 - Customize the Component Live Preview Key Concepts Recap Conclusion Resources 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 Here’s an overview of the component architecture you’ll create in this tutorial: 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. 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 oneof the following commands, depending on your package manager: pnpm npm Yarn Bun 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. Before writing any code, let’s review the full component and break it into logical pieces. Here is the complete implementation: Now, let’s break this down piece by piece. The The Each tab’s You can replace these placeholder Two pieces of state drive the entire component: 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). The magic here is The transition config uses a spring with The Let’s unpack the visual logic for each Each card behind the active one is scaled down by 10% per layer. So: Active card (idx 0): Second card (idx 1): Third card (idx 2): This creates clear depth separation between the stacked layers. When Negative z-index stacks cards in order: the active card sits on top (z-index 0), while subsequent cards descend further behind. Cards at index 3 and beyond are hidden entirely. The first three cards fade progressively: 1.0, 0.9, 0.8. Only the active card (idx 0) gets this keyframe animation. When a tab is selected, and the Each card also has a The outer wrapper applies The Because You can also replace the placeholder content panels with real content. Here’s an example using a card with a real description: 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 In this tutorial, you built a fully animated, theme-aware tab component using Shadcn/ui and Framer Motion. You learned how to: Use Render all tab panels simultaneously and reorder them to create a stacked card effect Drive hover and depth effects with inline reactive Apply 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 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. Shadcn Space Tabs Component Shadcn Space Getting Started Guide Framer Motion Documentation Shadcn/ui Documentation Video Tutorial on YouTube I wrote this article with the help of Mihir Koshti (Sr. Full Stack Developer) – Connect on LinkedIn.<Tabs/>component with:Prerequisites
What You’ll Build
AnimatedTabMotion (page/demo entry point)└── Tabs (tab bar + content orchestrator)├── Tab buttons (with spring-animated active pill)└── FadeInStack (stacked, animated content panels)Install the Component via Shadcn Space CLI
pnpm dlx shadcn@latest add @shadcn-space/tabs-01npx shadcn@latest add @shadcn-space/tabs-01yarn dlx shadcn@latest add @shadcn-space/tabs-01bunx --bun shadcn@latest add @shadcn-space/tabs-01Understand the Component Structure
"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> </> );}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;};Tabtype 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 Motion layoutId).content– an optional React.ReactNode, meaning you can pass any JSX as the panel body.TabsPropstype makes the Tabscomponent 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 ];contentis a JSX element styled with Shadcn/ui semantic tokens like bg-muted, text-foregroundand border-border. This is intentional: these tokens automatically adapt to your light/dark theme without any extra configuration.<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);activeIdxtracks which tab is currently selected (by array index).hoveringtracks whether the user’s cursor is over any tab button, which is passed to FadeInStackto trigger the fan-out effect.Reorder Tabs for the Stack Effect
const reorderedTabs = [tabs[activeIdx],…tabs.filter((_, i) => i !== activeIdx),];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>);})}layoutId=“clickedbutton”on the motion.div. When only one element with a given layoutIdis 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 transitionis between the two DOM positions. This creates the sliding pill effect with zero manual calculation.bounce: 0.3a duration: 0.6, giving it a natural, slightly elastic feel rather than a mechanical linear slide.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> );};motion.div:scale: 1 - idx * 0.1scale: 1.0scale: 0.9scale: 0.8top: hovering ? idx * -15 : 0hoveringis 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: -idxopacity: idx < 3 ? 1 - idx * 0.1 : 0animate={ { y: idx === 0 ? [0, 40, 0] : 0 }}reorderedTabsarray 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}layoutIdmatching one value. When reorderedTabsis 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> );}[perspective:1000px]– a Tailwind arbitrary property that sets the CSS perspectivevalue. This is what gives the 3D depth to the transformStyle: “preserve-3d”on the tab buttons.max-w-5xland mx-autocenter the component on wide screens while items-startleft-aligns the tab bar, which matches most real-world UI patterns.Step 6: Customize the Component
Tabsaccepts 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"/>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
layoutIdon motion.divlayoutIdon motion.divper tabanimate={ { y: [0, 40, 0] }}style={ { scale, top, zIndex, opacity }}transition={ { type: "spring" }}Conclusion
layoutIdto create a spring-animated sliding pill indicatorstylepropslayoutIdand stack reorder technique to carousels, image galleries, notification toasts, and more.Resources
相关推荐
-
The Docker Handbook – Learn Docker for Beginners
-
How to Deploy an AI Agent with Amazon Bedrock AgentCore
-
CSS Functions Guide
-
How to Build a Live Options Database in Python – A Complete Guide
-
How to Build Optimal AI Agents That Actually Work – A Handbook for Devs
-
How to Generate PDF Files in the Browser Using JavaScript (With a Real Invoice Example)
- 最近发表
-
- The Express + Node.js Handbook – Learn the Express JavaScript Framework for Beginners
- AI in Finance: Transforming Investments and Banking in the Digital Age
- How to Create a GPU
- How to Ship a Production
- How to Build a Team of AI Agents for Your Website for Free Using Agno and Groq
- rotateX()
- How to Containerize Your MLOps Pipeline from Training to Serving
- AI in Agriculture: How AI
- Domain Management Best Practices
- How to Build an Adaptive Tic
- 随机阅读
-
- Key Technical Design Decisions for Building an Educational App with LLMs
- Deep Reinforcement Learning in Natural Language Understanding
- How to Build a Browser
- How to Build an Animated Shadcn Tab Component with Shadcn/ui
- Reporting System Development
- How to Apply Academic Theories to Human
- How to Clean Time Series Data in Python
- How to Use Bash & Python for Real DevOps Automation – Full Handbook with 5 Production Use Cases
- How to Convert Images to PDF in the Browser Using JavaScript – A Step
- How to Build a Barcode Generator Using JavaScript (Step
- Pioneering Next
- How to Use AI Effectively in Your Dev Projects
- Backend Challenges Teams Face When Processing Repeat Payments
- How to Build a Cost
- What’s !important #13: @function, alpha(), CSS Wordle, and More
- How to Build an Automatic Knowledge Graph for Your Blog with PHP and JSON
- Learn Linux for Beginners: From Basics to Advanced Techniques [Full Book]
- freeCodeCamp's Terms of Service
- Another Stab at the Perfect CSS Pie Chart... Sans JavaScript!
- How to Merge PDF Files in the Browser Using JavaScript (Step
- 搜索
-