Radix Tabs with a pill or underline indicator that slides between triggers on a spring, and panels that fade in. Keeps full keyboard support and ARIA; controlled or uncontrolled.
Requests, last 12 hours
1.28M
import { Activity, Gauge, Rocket, Settings2 } from "lucide-react"
import {
AnimatedTabs,
AnimatedTabsContent,
AnimatedTabsList,
AnimatedTabsTrigger,
} from "@/components/ballmac/animated-tabs"
const bars = [42, 58, 50, 72, 64, 80, 76, 92, 70, 88, 96, 84]
export default function AnimatedTabsDemo() {
return (
<AnimatedTabs defaultValue="overview" className="w-full max-w-md">
<AnimatedTabsList aria-label="Project" className="self-center">
<AnimatedTabsTrigger value="overview" className="max-sm:px-3 max-sm:[&_svg]:hidden">
<Gauge aria-hidden="true" />
Overview
</AnimatedTabsTrigger>
<AnimatedTabsTrigger value="activity" className="max-sm:px-3 max-sm:[&_svg]:hidden">
<Activity aria-hidden="true" />
Activity
</AnimatedTabsTrigger>
<AnimatedTabsTrigger value="deploys" className="max-sm:px-3 max-sm:[&_svg]:hidden">
<Rocket aria-hidden="true" />
Deploys
</AnimatedTabsTrigger>
<AnimatedTabsTrigger value="settings" aria-label="Settings">
<Settings2 aria-hidden="true" />
</AnimatedTabsTrigger>
</AnimatedTabsList>
<div className="min-h-52 rounded-xl border bg-card p-5 shadow-xs">
<AnimatedTabsContent value="overview">
<p className="text-sm text-muted-foreground">Requests, last 12 hours</p>
<p className="mt-1 text-2xl font-semibold tracking-tight tabular-nums">1.28M</p>
<div className="mt-4 flex h-24 items-end gap-1.5" aria-hidden="true">
{bars.map((h, i) => (
<span
key={i}
className="flex-1 rounded-t-sm bg-[linear-gradient(180deg,var(--chart-1),color-mix(in_oklch,var(--chart-1)_30%,transparent))]"
style={{ height: `${h}%` }}
/>
))}
</div>
</AnimatedTabsContent>
<AnimatedTabsContent value="activity">
<ul className="space-y-3 text-sm">
{[
["Maya merged", "feat/billing-v2", "2m"],
["Deploy promoted", "v2.4.0 → production", "9m"],
["Alert resolved", "p95 latency back under 200 ms", "31m"],
].map(([what, detail, when]) => (
<li key={what} className="flex items-start gap-3">
<span className="mt-1.5 size-1.5 shrink-0 rounded-full bg-chart-2" aria-hidden="true" />
<span className="flex-1">
<span className="font-medium">{what}</span>
<span className="block text-muted-foreground">{detail}</span>
</span>
<span className="font-mono text-xs text-muted-foreground">{when}</span>
</li>
))}
</ul>
</AnimatedTabsContent>
<AnimatedTabsContent value="deploys">
<div className="space-y-2 font-mono text-xs">
{[
["v2.4.0", "production", "Ready"],
["v2.4.0-rc.2", "preview", "Ready"],
["v2.3.9", "production", "Retired"],
].map(([v, env, state]) => (
<div key={v} className="flex items-center gap-3 rounded-md border bg-background px-3 py-2">
<span className="font-medium">{v}</span>
<span className="text-muted-foreground">{env}</span>
<span className="ms-auto">{state}</span>
</div>
))}
</div>
</AnimatedTabsContent>
<AnimatedTabsContent value="settings">
<p className="text-sm font-medium">Project settings</p>
<p className="mt-1 text-sm text-muted-foreground">
Rename the project, rotate deploy keys or transfer it to another team.
</p>
</AnimatedTabsContent>
</div>
</AnimatedTabs>
)
}Installation
$ pnpm dlx shadcn@latest add @ballmac/animated-tabsInstall the dependencies.
$ pnpm add motion@^12 radix-uiAdd the Ballmac items it builds on.
$ pnpm dlx shadcn@latest add @ballmac/motion-presetsCopy the source into your project.
components/ballmac/animated-tabs.tsx// Ballmac UI: Animated Tabs. https://ui.ballmac.com/components/animated-tabs "use client" import * as React from "react" import { motion, useReducedMotion } from "motion/react" import { Tabs as TabsPrimitive } from "radix-ui" import { cn } from "@/lib/utils" import { duration, ease, spring } from "@/lib/ballmac/motion" type AnimatedTabsVariant = "pill" | "underline" type AnimatedTabsContextValue = { value: string; indicatorId: string; variant: AnimatedTabsVariant } const AnimatedTabsContext = React.createContext<AnimatedTabsContextValue | null>(null) function useAnimatedTabs(part: string) { const context = React.useContext(AnimatedTabsContext) if (!context) throw new Error(`<${part}> must be used inside <AnimatedTabs>`) return context } type AnimatedTabsProps = React.ComponentProps<typeof TabsPrimitive.Root> & { /** Indicator style: a sliding pill behind the label, or a sliding underline. */ variant?: AnimatedTabsVariant } function AnimatedTabs({ variant = "pill", value: valueProp, defaultValue = "", onValueChange, className, ...props }: AnimatedTabsProps) { const [internal, setInternal] = React.useState(defaultValue) const value = valueProp ?? internal const indicatorId = `animated-tabs-${React.useId()}` const context = React.useMemo(() => ({ value, indicatorId, variant }), [value, indicatorId, variant]) return ( <AnimatedTabsContext.Provider value={context}> <TabsPrimitive.Root data-slot="animated-tabs" data-variant={variant} value={value} onValueChange={(next) => { if (valueProp === undefined) setInternal(next) onValueChange?.(next) }} className={cn("flex flex-col gap-4", className)} {...props} /> </AnimatedTabsContext.Provider> ) } function AnimatedTabsList({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.List>) { const { variant } = useAnimatedTabs("AnimatedTabsList") return ( <TabsPrimitive.List data-slot="animated-tabs-list" className={cn( "relative inline-flex w-fit max-w-full items-center overflow-x-auto [scrollbar-width:none]", variant === "pill" ? "h-10 gap-0.5 rounded-full bg-muted p-1" : "h-10 gap-5 border-b", className )} {...props} /> ) } function AnimatedTabsTrigger({ value, className, children, ...props }: React.ComponentProps<typeof TabsPrimitive.Trigger>) { const { value: selected, indicatorId, variant } = useAnimatedTabs("AnimatedTabsTrigger") const reduceMotion = useReducedMotion() const active = selected === value return ( <TabsPrimitive.Trigger data-slot="animated-tabs-trigger" value={value} className={cn( "relative inline-flex h-full shrink-0 items-center justify-center gap-1.5 text-sm font-medium whitespace-nowrap text-muted-foreground outline-none transition-colors duration-150 select-none hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", variant === "pill" ? "rounded-full px-3.5" : "rounded-sm px-0.5", className )} {...props} > {active && ( <motion.span aria-hidden="true" data-slot="animated-tabs-indicator" layoutId={indicatorId} transition={reduceMotion ? { duration: 0 } : spring.snappy} className={cn( "pointer-events-none absolute", variant === "pill" ? "inset-0 rounded-full bg-background shadow-[0_1px_2px_rgb(0_0_0/0.08),0_0_0_1px_color-mix(in_oklch,var(--foreground)_6%,transparent)] dark:bg-accent" : "inset-x-0 bottom-0 h-0.5 rounded-full bg-foreground" )} /> )} <span className="relative z-10 inline-flex items-center gap-1.5">{children}</span> </TabsPrimitive.Trigger> ) } function AnimatedTabsContent({ className, children, ...props }: React.ComponentProps<typeof TabsPrimitive.Content>) { const reduceMotion = useReducedMotion() return ( <TabsPrimitive.Content data-slot="animated-tabs-content" className={cn("outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 rounded-md", className)} {...props} > {/* Radix mounts only the selected panel, so each panel fades in as it arrives. */} <motion.div initial={reduceMotion ? false : { opacity: 0, y: 6, filter: "blur(4px)" }} animate={{ opacity: 1, y: 0, filter: "blur(0px)" }} transition={{ duration: duration.slow, ease: ease.out }} > {children} </motion.div> </TabsPrimitive.Content> ) } export { AnimatedTabs, AnimatedTabsList, AnimatedTabsTrigger, AnimatedTabsContent, type AnimatedTabsProps, type AnimatedTabsVariant, }Update the import paths to match your project setup.
Usage
import { AnimatedTabs, AnimatedTabsList, AnimatedTabsTrigger, AnimatedTabsContent } from "@/components/ballmac/animated-tabs"The full example is in the Code tab above.
Examples
Underline
Your name, email and the avatar teammates see.
import {
AnimatedTabs,
AnimatedTabsContent,
AnimatedTabsList,
AnimatedTabsTrigger,
} from "@/components/ballmac/animated-tabs"
const sections = {
account: "Your name, email and the avatar teammates see.",
security: "Passkeys, sessions and two-step verification.",
billing: "Plan, invoices and the card on file.",
notifications: "Choose what reaches your inbox and what stays in the app.",
}
export default function AnimatedTabsUnderline() {
return (
<AnimatedTabs variant="underline" defaultValue="account" className="w-full max-w-md">
<AnimatedTabsList aria-label="Settings">
{Object.keys(sections).map((key) => (
<AnimatedTabsTrigger key={key} value={key} className="capitalize">
{key}
</AnimatedTabsTrigger>
))}
</AnimatedTabsList>
{Object.entries(sections).map(([key, text]) => (
<AnimatedTabsContent key={key} value={key}>
<p className="text-sm text-muted-foreground">{text}</p>
</AnimatedTabsContent>
))}
</AnimatedTabs>
)
}API reference
| Prop | Type | Default |
|---|---|---|
variantIndicator style: a sliding pill behind the label, or a sliding underline. | AnimatedTabsVariant | "pill" |
Also accepts the standard attributes of its root element.
Accessibility
| Key | Action |
|---|---|
| Left / Right | Moves focus between tabs and selects them |
| Home / End | Jumps to the first or last tab |
| Tab | Moves from the tab list into the selected panel |
Use with AI
Same API as Radix Tabs: <AnimatedTabs defaultValue variant="pill" | "underline"> with AnimatedTabsList, AnimatedTabsTrigger value and AnimatedTabsContent value. The indicator is a Motion layoutId element inside the active trigger. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Animated Tabs (@ballmac/animated-tabs) to this project with the shadcn MCP, then use it where it fits.
Use it for
- Switching between views of the same object (overview, activity, settings)
- Settings pages with a few sections
- Pricing toggles such as monthly and yearly
Not for
- Page-level navigation between routes (use links)
- More than about six options (use a select)
- Package-manager command tabs (use install-tabs)
Registry JSON: https://ui.ballmac.com/r/animated-tabs.json
Credits
Free to use in personal and commercial projects.
Pairs well with
Install Tabs
Package-manager tabs for a CLI command (pnpm, npm, yarn, bun) with a copy button. Remembers the reader's choice and keeps every instance on the page in sync.
Code Block
A code panel with filename and language header, line numbers, highlighted lines, a wrap toggle, copy feedback and file tabs. No highlighter bundled; pass Shiki output as children.
Back To Top
A floating button that appears after scrolling, draws a progress ring, scrolls smoothly (instantly under reduced motion) and moves focus to the content.
Breadcrumb
A compact navigation trail with truncation, semantic current page, and visible keyboard focus.