Skip to content
jjswnth/ui

Navigation

Animated Tabs

A tab bar with a spring-loaded pill that slides between the active item.

A summary of everything happening in the workspace.

Installation

  1. 1. Install the dependencies.

    terminal
    npm install motion clsx tailwind-merge
  2. 2. Add the cn helper, if you don't already have it.

    lib/utils.ts
    import { clsx, type ClassValue } from "clsx";
    import { twMerge } from "tailwind-merge";
    
    export function cn(...inputs: ClassValue[]) {
      return twMerge(clsx(inputs));
    }
  3. 3. Copy the component into your project.

    components/ui/animated-tabs.tsx
    "use client";
    
    import { motion } from "motion/react";
    import { useId, useState } from "react";
    import { cn } from "@/lib/utils";
    
    type Tab = { id: string; label: string };
    
    type AnimatedTabsProps = {
      tabs: Tab[];
      className?: string;
      /** Uncontrolled starting tab. Defaults to the first one. */
      defaultTab?: string;
      onChange?: (id: string) => void;
    };
    
    export function AnimatedTabs({
      tabs,
      className,
      defaultTab,
      onChange,
    }: AnimatedTabsProps) {
      const [active, setActive] = useState(defaultTab ?? tabs[0]?.id);
      // Keeps the sliding pill scoped to this instance when several are on a page.
      const layoutId = useId();
    
      return (
        <div
          role="tablist"
          className={cn(
            "inline-flex items-center gap-1 rounded-full border border-line bg-elevated p-1",
            className,
          )}
        >
          {tabs.map((tab) => {
            const isActive = tab.id === active;
    
            return (
              <button
                key={tab.id}
                type="button"
                role="tab"
                aria-selected={isActive}
                onClick={() => {
                  setActive(tab.id);
                  onChange?.(tab.id);
                }}
                className={cn(
                  "relative rounded-full px-4 py-1.5 text-sm font-medium transition-colors",
                  "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent",
                  isActive ? "text-accent-ink" : "text-muted hover:text-ink",
                )}
              >
                {isActive ? (
                  <motion.span
                    layoutId={layoutId}
                    transition={{ type: "spring", stiffness: 380, damping: 32 }}
                    className="absolute inset-0 rounded-full bg-accent"
                  />
                ) : null}
                <span className="relative">{tab.label}</span>
              </button>
            );
          })}
        </div>
      );
    }

Props

PropTypeDefaultDescription
tabs{ id: string; label: string }[]Tabs to render, in order.
defaultTabstringtabs[0].idUncontrolled starting tab.
onChange(id: string) => voidFires with the id of the newly selected tab.
classNamestringMerged onto the tab list.