nico.fyi
    Published on

    How to have animated nav tabs with React and Tailwind CSS

    Just like the one in Vercel's dashboard

    Authors

    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 LinkNavTabscomponent below, which wraps theAnimatedNavTabs component.

    link-nav-tabs.tsx
    '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:

    page.tsx
    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!