navui
uianimated-tabssource: ok · hit
Animated Tabs
A tab component with a moving active pill and directional content transitions.
npx shadcn add "https://ui.navdeepsingh.dev/r/animated-tabs.json"free
No selection tags yet — empty means no evidence, not lack of support.
Dependencies
Bundle weight · mediumui-primitives@radix-ui/react-use-controllable-state
animationmotion
Registry dependencies
tabs
Files1 · source cached 10/9/2026, 10:16:26 PM
src/registry/components/animated-tabs/animated-tabs.tsx
'use client'
import * as React from 'react'
import { motion, useReducedMotion } from 'motion/react'
import { useControllableState } from '@radix-ui/react-use-controllable-state'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { cn } from '@/lib/utils'
type AnimatedTabsContextValue = {
value: string
previousValue: string | null
direction: number
activationMode: 'automatic' | 'manual'
shouldReduceMotion: boolean
setValue: (value: string) => void
registerTrigger: (value: string) => () => void
}
const AnimatedTabsContext = React.createContext<AnimatedTabsContextValue | null>(null)
function useAnimatedTabs() {
const context = React.useContext(AnimatedTabsContext)
if (!context) {
throw new Error('AnimatedTabs components must be used within <AnimatedTabs>.')
}
return context
}
function AnimatedTabs({
value: valueProp,
defaultValue,
onValueChange,
activationMode = 'automatic',
className,
children,
...props
}: React.ComponentProps<typeof Tabs>) {
const triggerValues = React.useRef<string[]>([])
const shouldReduceMotion = useReducedMotion()
const lastValue = React.useRef(valueProp ?? defaultValue ?? '')
const [previousValue, setPreviousValue] = React.useState<string | null>(null)
const [direction, setDirection] = React.useState(0)
const [value = '', setValue] = useControllableState({
prop: valueProp,
defaultProp: defaultValue ?? '',
onChange: onValueChange,
})
const registerTrigger = React.useCallback((triggerValue: string) => {
if (!triggerValues.current.includes(triggerValue)) {
triggerValues.current.push(triggerValue)
}
return () => {
triggerValues.current = triggerValues.current.filter((value) => value !== triggerValue)
}
}, [])
React.useLayoutEffect(() => {
const previous = lastValue.current
if (!value || previous === value) return
const previousIndex = triggerValues.current.indexOf(previous)
const nextIndex = triggerValues.current.indexOf(value)
setPreviousValue(previous || null)
setDirection(previousIndex === -1 || nextIndex === -1 ? 0 : nextIndex > previousIndex ? 1 : -1)
lastValue.current = value
}, [value])
React.useEffect(() => {
if (!previousValue) return
const timeout = window.setTimeout(() => setPreviousValue(null), 240)
return () => window.clearTimeout(timeout)
}, [previousValue])
return (
<AnimatedTabsContext.Provider
value={{
value,
previousValue,
direction,
activationMode,
shouldReduceMotion: Boolean(shouldReduceMotion),
setValue,
registerTrigger,
}}
>
<Tabs
data-slot="animated-tabs"
value={value}
onValueChange={setValue}
activationMode={activationMode}
className={cn('w-full', className)}
{...props}
>
{children}
</Tabs>
</AnimatedTabsContext.Provider>
)
}
function AnimatedTabsList({
className,
children,
ref,
...props
}: React.ComponentProps<typeof TabsList>) {
const context = useAnimatedTabs()
const listRef = React.useRef<React.ComponentRef<typeof TabsList>>(null)
const [indicator, setIndicator] = React.useState({ x: 0, width: 0, ready: false })
const setListRef = React.useCallback(
(node: React.ComponentRef<typeof TabsList> | null) => {
listRef.current = node
if (typeof ref === 'function') ref(node)
else if (ref) ref.current = node
},
[ref],
)
const measureIndicator = React.useCallback(() => {
const list = listRef.current
const activeTrigger = list?.querySelector<HTMLElement>(
'[data-slot="animated-tabs-trigger"][data-state="active"]',
)
if (!list || !activeTrigger) return
setIndicator((current) => {
const next = {
x: activeTrigger.offsetLeft,
width: activeTrigger.offsetWidth,
ready: true,
}
return current.x === next.x && current.width === next.width && current.ready === next.ready
? current
: next
})
}, [])
React.useLayoutEffect(measureIndicator, [context.value, children, measureIndicator])
React.useEffect(() => {
if (typeof ResizeObserver === 'undefined' || !listRef.current) return
const observer = new ResizeObserver(measureIndicator)
observer.observe(listRef.current)
const activeTrigger = listRef.current.querySelector<HTMLElement>(
'[data-slot="animated-tabs-trigger"][data-state="active"]',
)
if (activeTrigger) observer.observe(activeTrigger)
return () => observer.disconnect()
}, [context.value, measureIndicator])
return (
<TabsList
data-slot="animated-tabs-list"
ref={setListRef}
className={cn(
'relative isolate h-auto min-h-0 w-full justify-start gap-1 overflow-x-auto rounded-xl p-1',
className,
)}
{...props}
>
<motion.span
data-slot="animated-tabs-indicator"
aria-hidden="true"
initial={false}
animate={{
x: indicator.x,
width: indicator.width,
opacity: indicator.ready ? 1 : 0,
}}
transition={
context.shouldReduceMotion
? { duration: 0 }
: { type: 'spring', stiffness: 420, damping: 32 }
}
className="pointer-events-none absolute top-1 bottom-1 left-0 z-0 rounded-lg bg-background shadow-sm will-change-transform"
/>
{children}
</TabsList>
)
}
function AnimatedTabsTrigger({
value,
className,
children,
onClick,
onFocus,
...props
}: React.ComponentProps<typeof TabsTrigger>) {
const context = useAnimatedTabs()
const registerTrigger = context.registerTrigger
React.useLayoutEffect(() => registerTrigger(value), [registerTrigger, value])
function handleClick(event: React.MouseEvent<HTMLButtonElement>) {
onClick?.(event)
if (!event.defaultPrevented) context.setValue(value)
}
function handleFocus(event: React.FocusEvent<HTMLButtonElement>) {
onFocus?.(event)
if (!event.defaultPrevented && context.activationMode === 'automatic') {
context.setValue(value)
}
}
return (
<TabsTrigger
data-slot="animated-tabs-trigger"
value={value}
onClick={handleClick}
onFocus={handleFocus}
className={cn(
'relative isolate z-10 flex-1 overflow-hidden rounded-lg px-4 py-2 text-muted-foreground shadow-none',
'data-[state=active]:bg-transparent data-[state=active]:text-foreground data-[state=active]:shadow-none',
className,
)}
{...props}
>
<span className="relative z-10">{children}</span>
</TabsTrigger>
)
}
function AnimatedTabsViewport({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="animated-tabs-viewport"
className={cn('grid overflow-hidden outline-none', className)}
{...props}
/>
)
}
function AnimatedTabsContent({
value,
className,
children,
tabIndex,
...props
}: React.ComponentProps<typeof TabsContent>) {
const context = useAnimatedTabs()
const isActive = context.value === value
const isPrevious = !context.shouldReduceMotion && context.previousValue === value
const isVisible = isActive || isPrevious
const animation = isActive
? { opacity: 1, x: 0, filter: 'blur(0px)' }
: {
opacity: 0,
x: context.shouldReduceMotion
? 0
: context.direction > 0
? -32
: context.direction < 0
? 32
: 0,
filter: context.shouldReduceMotion ? 'blur(0px)' : 'blur(4px)',
}
return (
<TabsContent
data-slot="animated-tabs-content"
value={value}
forceMount
aria-hidden={!isActive}
tabIndex={isActive ? tabIndex : -1}
className={cn(
'col-start-1 row-start-1 min-w-0 outline-none',
!isVisible && 'hidden',
className,
)}
{...props}
>
<motion.div
initial={false}
animate={animation}
transition={{ duration: context.shouldReduceMotion ? 0 : 0.22, ease: 'easeOut' }}
>
{children}
</motion.div>
</TabsContent>
)
}
export {
AnimatedTabs,
AnimatedTabsContent,
AnimatedTabsList,
AnimatedTabsTrigger,
AnimatedTabsViewport,
}