Skip to content
motion basics
Back to the collection

STUDY / 002 · Navigation

Animated tabs

A shared background that follows your focus.

Layout animation

Preview

A place for the bigger picture.

Live preview · Go ahead, interact with it

Code

"use client";
import { LayoutGroup, motion, useReducedMotion } from "motion/react";
import { useId, useState } from "react";
import { cn } from "@/lib/utils";

const tabs = ["Overview", "Activity", "Settings"];
export default function AnimatedTabs({ className }: { className?: string }) {
  const [active, setActive] = useState("Overview");
  const id = useId();
  const reduced = useReducedMotion();
  return (
    <LayoutGroup id={id}>
      <div className={cn("w-full max-w-72 text-center", className)}>
        <div
          className="flex rounded-xl border border-neutral-200/80 dark:border-neutral-800 bg-neutral-100 dark:bg-neutral-900 p-1"
          role="tablist"
          aria-label="Example navigation"
        >
          {tabs.map((tab, index) => (
            <button
              key={tab}
              type="button"
              role="tab"
              aria-selected={active === tab}
              aria-controls={`${id}-panel`}
              id={`${id}-${tab}`}
              tabIndex={active === tab ? 0 : -1}
              onKeyDown={(event) => {
                let next = index;
                if (event.key === "ArrowRight")
                  next = (index + 1) % tabs.length;
                else if (event.key === "ArrowLeft")
                  next = (index + tabs.length - 1) % tabs.length;
                else if (event.key === "Home") next = 0;
                else if (event.key === "End") next = tabs.length - 1;
                else return;
                event.preventDefault();
                setActive(tabs[next]);
                document.getElementById(`${id}-${tabs[next]}`)?.focus();
              }}
              onClick={() => setActive(tab)}
              className="relative flex-1 rounded-lg px-3 py-2.5 text-xs font-medium text-neutral-500 dark:text-neutral-400 aria-selected:text-neutral-900 dark:aria-selected:text-white"
            >
              {active === tab && (
                <motion.span
                  layoutId="active-tab"
                  transition={{
                    type: "spring",
                    duration: reduced ? 0 : 0.3,
                    bounce: 0.1,
                  }}
                  className="absolute inset-0 rounded-lg bg-white dark:bg-[#0a0a0a] shadow-sm ring-1 ring-black/5 dark:ring-white/10"
                />
              )}
              <span className="relative">{tab}</span>
            </button>
          ))}
        </div>
        <p
          role="tabpanel"
          id={`${id}-panel`}
          aria-labelledby={`${id}-${active}`}
          className="mt-7 text-xs text-neutral-400 dark:text-neutral-500"
        >
          {active === "Overview"
            ? "A place for the bigger picture."
            : active === "Activity"
              ? "Every little interaction, in motion."
              : "Make yourself at home."}
        </p>
      </div>
    </LayoutGroup>
  );
}

React · TypeScript · Tailwind CSS

Copy the component and the cn helper into your project.

bun add motion lucide-react clsx tailwind-merge