Loading preview…
Installation
terminal
terminal
terminal
terminal
Or, with @layro in your components.json:
terminal
Code
"use client"
import { ReactNode, useMemo, useState } from "react"
import { AnimatePresence, motion, MotionConfig } from "motion/react"
import useMeasure from "react-use-measure"
import { cn } from "@/lib/utils"
type Tab = {
id: number
label: string
content: ReactNode
}
interface OgImageSectionProps {
tabs: Tab[]
className?: string
/** Outer container radius (e.g. `rounded-lg`) */
rounded?: string
/** Inner tab/bubble radius — should be outer radius minus container padding (~3px) */
roundedInner?: string
onChange?: () => void
}
function DirectionAwareTabs({
tabs,
className,
rounded,
roundedInner,
onChange,
}: OgImageSectionProps) {
const [activeTab, setActiveTab] = useState(0)
const [direction, setDirection] = useState(0)
const [isAnimating, setIsAnimating] = useState(false)
const [ref, bounds] = useMeasure()
const content = useMemo(() => {
const activeTabContent = tabs.find((tab) => tab.id === activeTab)?.content
return activeTabContent || null
}, [activeTab, tabs])
const handleTabClick = (newTabId: number) => {
if (newTabId !== activeTab && !isAnimating) {
const newDirection = newTabId > activeTab ? 1 : -1
setDirection(newDirection)
setActiveTab(newTabId)
onChange ? onChange() : null
}
}
const variants = {
initial: (direction: number) => ({
x: 300 * direction,
opacity: 0,
filter: "blur(4px)",
}),
active: {
x: 0,
opacity: 1,
filter: "blur(0px)",
},
exit: (direction: number) => ({
x: -300 * direction,
opacity: 0,
filter: "blur(4px)",
}),
}
return (
<div className="flex w-full flex-col items-center">
<div
className={cn(
"shadow-inner-shadow flex cursor-pointer space-x-1 rounded-full border border-none bg-neutral-600 px-[3px] py-[3.2px]",
className,
rounded
)}
>
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => handleTabClick(tab.id)}
className={cn(
"relative flex items-center gap-2 rounded-full px-3.5 py-1.5 text-xs font-medium text-neutral-200 transition focus-visible:ring-1 focus-visible:outline-1 focus-visible:outline-none sm:text-sm",
activeTab === tab.id
? "text-white"
: "text-neutral-200/80 hover:text-neutral-300/60",
rounded ? roundedInner : undefined
)}
style={{ WebkitTapHighlightColor: "transparent" }}
>
{activeTab === tab.id && (
<motion.span
layoutId="bubble"
className={cn(
"shadow-inner-shadow absolute inset-0 z-10 border border-white/10 bg-neutral-700 mix-blend-difference",
rounded ? roundedInner : "rounded-full"
)}
transition={{ type: "spring", bounce: 0.19, duration: 0.4 }}
/>
)}
{tab.label}
</button>
))}
</div>
<MotionConfig transition={{ duration: 0.4, type: "spring", bounce: 0.2 }}>
<motion.div
className="relative mx-auto h-full w-full overflow-hidden"
initial={false}
animate={{ height: bounds.height }}
>
<div className="p-1" ref={ref}>
<AnimatePresence
custom={direction}
mode="popLayout"
onExitComplete={() => setIsAnimating(false)}
>
<motion.div
key={activeTab}
variants={variants}
initial="initial"
animate="active"
exit="exit"
custom={direction}
onAnimationStart={() => setIsAnimating(true)}
onAnimationComplete={() => setIsAnimating(false)}
>
{content}
</motion.div>
</AnimatePresence>
</div>
</motion.div>
</MotionConfig>
</div>
)
}
export { DirectionAwareTabs }Versions
1.0.0 · current
Oct 30, 2026
First release.







