- Published on
How to have animated nav tabs with React and Tailwind CSS
Just like the one in Vercel's dashboard
- Authors
- Name
- Nico Prananta
- Follow me on Bluesky
I was intrigued by the navigation tabs in Vercel's dashboard, as shown in the video above. When the user clicks on a tab, the tab indicator animates to the position of the selected tab and also animates the width to match the width of the selected tab. It also displays a hover background that animates to the position of the hovered tab. It's so slick.
So I made one using Tailwind CSS and Open Props for the animation.
I honestly don't know how to do this with just CSS. I don't think it's possible. So I did the obvious. I set the position and width of the tab indicator to the position and width of the selected tab using React's ref and useEffect.
// ... other code
const tabIndicatorRef = useRef<HTMLDivElement | null>(null)
const tabRefs = useRef<Array<HTMLDivElement | null>>([])
useEffect(() => {
// Find the active tab based on the current pathname. Compare the pathname with the data-path attribute of the tab's anchor element.
const activeTabRef = tabRefs.current.find((ref) => ref?.dataset.path === activeTab?.path)
if (activeTabRef && tabIndicatorRef.current) {
// Set the width of the tab indicator to the width of the active tab.
tabIndicatorRef.current.style.width = `${activeTabRef.offsetWidth}px`
// Set the left position of the tab indicator to the left position of the active tab.
tabIndicatorRef.current.style.left = `${activeTabRef.offsetLeft}px`
}
}, [activeTab])
return (
// ... other code
<div
ref={tabIndicatorRef}
// this div animates the width and its left position usong the transition-all class
className={cn(
'absolute bottom-0 z-10 transition-all motion-reduce:transition-none',
springy ? 'duration-500 ease-spring-4' : 'duration-150 ease-linear'
)}
>
<div className="h-1 bg-primary" />
</div>
)
The hover background is a bit tricky. I needed to set the position and width of the hover background to the position and width of the hovered tab. However, I only needed to hide the hover background when the cursor moved out of the tab container, so that it would still animate to the position of the next hovered tab.
// this effect is used to show and hide the hover background when the mouse enters and leaves the tabs
useEffect(() => {
const tabsElements = tabRefs.current
const tabContainer = tabContainerRef.current
const handleMouseEnter = (event: MouseEvent) => {
const target = event.target as HTMLElement // Type assertion here
if (hoverBgRef.current) {
hoverBgRef.current.style.width = `${target.offsetWidth}px`
hoverBgRef.current.style.left = `${target.offsetLeft}px`
hoverBgRef.current.style.opacity = '1'
}
}
tabsElements.forEach((tab) => {
tab?.addEventListener('mouseenter', handleMouseEnter)
})
const handleMouseLeave = () => {
if (hoverBgRef.current) {
hoverBgRef.current.style.opacity = '0'
}
}
tabContainer?.addEventListener('mouseleave', handleMouseLeave)
return () => {
tabsElements.forEach((tab) => {
tab?.removeEventListener('mouseenter', handleMouseEnter)
})
tabContainer?.removeEventListener('mouseleave', handleMouseLeave)
}
}, [])
Here's all the code, which you can easily copy and paste into your project. You will need to have Tailwind CSS configured in your project. If you want the "springy" animation, you will need to install Open Props first.
'use client'
import React, { useEffect, useRef } from 'react'
import { cn } from '@/lib/utils'
export const AnimatedNavTabs = ({
tabs,
springy,
}: {
tabs: Array<{ label: React.ReactNode; path: string; active: boolean }>
springy?: boolean
}) => {
const tabContainerRef = useRef<HTMLDivElement | null>(null)
const tabIndicatorRef = useRef<HTMLDivElement | null>(null)
const tabRefs = useRef<Array<HTMLDivElement | null>>([])
const hoverBgRef = useRef<HTMLDivElement | null>(null)
const activeTab = tabs.find((tab) => tab.active)
// this effect is used to animate the tab indicator when the active tab changes
useEffect(() => {
// Find the active tab based on the current pathname. Compare the pathname with the data-path attribute of the tab's anchor element.
const activeTabRef = tabRefs.current.find((ref) => ref?.dataset.path === activeTab?.path)
if (activeTabRef && tabIndicatorRef.current) {
// Set the width of the tab indicator to the width of the active tab.
tabIndicatorRef.current.style.width = `${activeTabRef.offsetWidth}px`
// Set the left position of the tab indicator to the left position of the active tab.
tabIndicatorRef.current.style.left = `${activeTabRef.offsetLeft}px`
}
}, [activeTab])
// this effect is used to show and hide the hover background when the mouse enters and leaves the tabs
useEffect(() => {
const tabsElements = tabRefs.current
const tabContainer = tabContainerRef.current
const handleMouseEnter = (event: MouseEvent) => {
const target = event.target as HTMLElement // Type assertion here
if (hoverBgRef.current) {
hoverBgRef.current.style.width = `${target.offsetWidth}px`
hoverBgRef.current.style.left = `${target.offsetLeft}px`
hoverBgRef.current.style.opacity = '1'
}
}
tabsElements.forEach((tab) => {
tab?.addEventListener('mouseenter', handleMouseEnter)
})
const handleMouseLeave = () => {
if (hoverBgRef.current) {
hoverBgRef.current.style.opacity = '0'
}
}
tabContainer?.addEventListener('mouseleave', handleMouseLeave)
return () => {
tabsElements.forEach((tab) => {
tab?.removeEventListener('mouseenter', handleMouseEnter)
})
tabContainer?.removeEventListener('mouseleave', handleMouseLeave)
}
}, [])
return (
<div className="w-full">
<div className="relative">
<div
ref={tabContainerRef}
className="inline-flex h-12 w-full items-center justify-start rounded-none border-b bg-transparent px-2 text-muted-foreground"
role="tablist"
tabIndex={0}
>
{tabs.map((tab, idx) => (
<div
role="tab"
aria-selected={tab.active ? true : false}
tabIndex={0}
key={tab.path}
ref={(ref) => {
tabRefs.current[idx] = ref
return undefined
}}
data-path={tab.path}
data-state={tab.active ? 'active' : 'inactive'}
className={
'relative z-10 inline-flex h-12 items-center justify-center whitespace-nowrap rounded-none bg-transparent px-4 py-1 text-sm text-muted-foreground shadow-none ring-offset-background transition-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:text-foreground data-[state=active]:shadow-none'
}
>
{tab.label}
</div>
))}
{/* the hover background */}
<div
ref={hoverBgRef}
className={cn(
'absolute bottom-0 z-0 h-full py-2 transition-all motion-reduce:transition-none',
springy ? 'duration-500 ease-spring-4' : 'duration-150 ease-linear'
)}
style={{ opacity: 0 }}
>
<div className="h-full w-full rounded-sm bg-muted bg-opacity-10 " />
</div>
<div
ref={tabIndicatorRef}
// this div animates the width and its left position usong the transition-all class
className={cn(
'absolute bottom-0 z-10 transition-all motion-reduce:transition-none',
springy ? 'duration-500 ease-spring-4' : 'duration-150 ease-linear'
)}
>
<div className="h-1 bg-primary" />
</div>
</div>
</div>
</div>
)
}
Note that the AnimatedNavTabs
component above does not depend on Next.js. It can be used in any React project. If you want the tabs to be the <Link>
component of Next.js, and the selected tab depends on the current pathname and search params, you can use the LinkNavTabs
component below, which wraps theAnimatedNavTabs
component.
'use client'
import { usePathname } from 'next/navigation'
import { AnimatedNavTabs } from './animated-nav-tabs'
export const LinkNavTabs = ({
tabs,
springy,
}: {
tabs: Array<{ label: React.ReactNode; path: string }>
springy?: boolean
}) => {
const pathname = usePathname();
const searchParams = useSearchParams();
const searchParamsString = searchParams.toString();
const fullPath =
pathname + (searchParamsString.length > 0 ? "?" : "") + searchParamsString;
const runtimeTabs = tabs.map((tab) => ({
label: tab.label,
path: tab.path,
active: fullPath === tab.path,
}));
return <AnimatedNavTabs tabs={runtimeTabs} springy={springy} />
}
Here's an example of using the LinkNavTabs
above:
import { LinkNavTabs } from './link-nav-tabs'
import Link from 'next/link'
export default function Page() {
return (
<div className="flex h-screen flex-col items-center justify-center">
<LinkNavTabs tabs={[
{ label: <Link href="/">Home</Link>, path: '/' },
{ label: <Link href="/about">About</Link>, path: '/about' },
{ label: <Link href="/contact">Contact</Link>, path: '/contact' },
]} springy />
</div>
)
}
You can check out the demo of the LinkNavTabs
component here, where the pathname of the page changes when you click on a tab.
There's also another demo of the AnimatedNavTabs
component here where the page content changes when you click on a tab without changing the pathname.
By the way, I'm making a book about Pull Requests Best Practices. Check it out!