Overview

Segmented tabs where the active pill springs between tabs (a touch of bounce), a soft hover wash glides across inactive ones, and the active icon pops in from a slight blur. The panel slides in the direction you moved, crossfading through a subtle blur while its height springs to fit the new content, and keeps tracking content that changes later. Arrow keys (and Up/Down in grid layout) switch tabs with the same motion; reduced motion falls back to plain crossfades.

Installation

Use the following command to install the component:

$ pnpm dlx shadcn@latest add https://ui.sanjid.in/r/menu.json

Install the following dependencies:

$ pnpm add motion

Add util file

lib/utils.ts
tsx
import { ClassValue, clsx } from "clsx";import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {  return twMerge(clsx(inputs));}

Copy and paste the following code into your project.

components/ui/menu.tsx
tsx
"use client";
import React, { useEffect, useId, useRef, useState } from "react";import type { KeyboardEvent } from "react";import {  AnimatePresence,  LayoutGroup,  motion,  useReducedMotion,} from "motion/react";import { cn } from "@/lib/utils";
export type TabItem = {  id: string | number;  label: string;  icon?: React.ReactNode;  content: React.ReactNode;};
export type AnimatedTabsProps = {  tabs: TabItem[];  defaultActiveId?: string | number;  onChange?: (id: string | number) => void;  layout?: "horizontal" | "grid";  /** Show the active tab's label as a heading inside the panel. */  showTitle?: boolean;  className?: string;};
const PILL_SPRING = { type: "spring" as const, bounce: 0.15, duration: 0.42 };const HEIGHT_SPRING = { type: "spring" as const, bounce: 0, duration: 0.38 };const EASE_OUT = [0.23, 1, 0.32, 1] as const;
export function AnimatedTabs({  tabs,  defaultActiveId,  onChange,  layout = "horizontal",  showTitle = true,  className = "",}: AnimatedTabsProps) {  const reduce = useReducedMotion();  const uid = useId();  const [activeId, setActiveId] = useState(defaultActiveId ?? tabs[0]?.id);  const [direction, setDirection] = useState(1);  const [hovered, setHovered] = useState<string | number | null>(null);  const tabRefs = useRef(new Map<string | number, HTMLButtonElement>());
  // Panel height follows its content, including later changes (fonts, images).  const innerRef = useRef<HTMLDivElement>(null);  const [height, setHeight] = useState<number | "auto">("auto");  useEffect(() => {    const el = innerRef.current;    if (!el) return;    const ro = new ResizeObserver(([entry]) => setHeight(entry.contentRect.height));    ro.observe(el);    return () => ro.disconnect();  }, []);
  const activeIndex = Math.max(0, tabs.findIndex((t) => t.id === activeId));  const activeTab = tabs[activeIndex];
  const select = (id: string | number) => {    const next = tabs.findIndex((t) => t.id === id);    if (next < 0 || id === activeTab?.id) return;    setDirection(next > activeIndex ? 1 : -1);    setActiveId(id);    onChange?.(id);  };
  const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {    const cols = layout === "grid" ? 3 : 1;    let next = activeIndex;    if (event.key === "ArrowRight") next = (activeIndex + 1) % tabs.length;    else if (event.key === "ArrowLeft") next = (activeIndex - 1 + tabs.length) % tabs.length;    else if (layout === "grid" && event.key === "ArrowDown") next = Math.min(tabs.length - 1, activeIndex + cols);    else if (layout === "grid" && event.key === "ArrowUp") next = Math.max(0, activeIndex - cols);    else if (event.key === "Home") next = 0;    else if (event.key === "End") next = tabs.length - 1;    else return;    event.preventDefault();    const tab = tabs[next];    if (!tab) return;    select(tab.id);    tabRefs.current.get(tab.id)?.focus();  };
  if (!activeTab) return null;
  const radius = layout === "grid" ? "rounded-[11px]" : "rounded-full";  const tabId = (id: string | number) => `${uid}-tab-${id}`;  const panelId = `${uid}-panel`;
  const panelVariants = {    enter: (dir: number) =>      reduce ? { opacity: 0 } : { opacity: 0, x: dir * 28, filter: "blur(6px)", scale: 0.985 },    center: { opacity: 1, x: 0, filter: "blur(0px)", scale: 1 },    exit: (dir: number) =>      reduce ? { opacity: 0 } : { opacity: 0, x: dir * -28, filter: "blur(6px)", scale: 0.985 },  };
  return (    <div      className={cn(        "flex w-full flex-col gap-2.5 rounded-[22px] border border-(--color-rule) bg-(--color-paper-2)/60 p-2.5",        className,      )}    >      <LayoutGroup id={uid}>        <div          role="tablist"          aria-label="Sections"          onKeyDown={onKeyDown}          onPointerLeave={() => setHovered(null)}          className={cn(            "relative bg-(--color-paper) p-[3px]",            "shadow-[inset_0_0_0_1px_var(--color-rule),0_1px_2px_rgba(0,0,0,0.03)]",            layout === "grid"              ? "grid w-full grid-cols-2 gap-[3px] rounded-[14px] sm:grid-cols-3"              : "flex w-fit max-w-full gap-[3px] overflow-x-auto rounded-full [scrollbar-width:none]",          )}        >          {tabs.map((tab) => {            const isActive = tab.id === activeTab.id;            const isHovered = hovered === tab.id && !isActive;            return (              <button                key={tab.id}                ref={(el) => {                  if (el) tabRefs.current.set(tab.id, el);                  else tabRefs.current.delete(tab.id);                }}                type="button"                role="tab"                id={tabId(tab.id)}                aria-selected={isActive}                aria-controls={panelId}                tabIndex={isActive ? 0 : -1}                onClick={() => select(tab.id)}                onPointerEnter={() => setHovered(tab.id)}                className={cn(                  "group relative inline-flex h-9 shrink-0 items-center justify-center gap-1.5 px-3.5 text-[13px] font-medium outline-none",                  "transition-[color,transform] duration-200 ease-out active:scale-[0.97]",                  "focus-visible:ring-2 focus-visible:ring-(--color-focus) focus-visible:ring-offset-2 focus-visible:ring-offset-(--color-paper)",                  radius,                  isActive ? "text-(--color-paper)" : "text-(--color-neutral) hover:text-(--color-ink)",                )}              >                {/* Hover wash glides between inactive tabs */}                <AnimatePresence>                  {isHovered && (                    <motion.span                      layoutId="hover"                      aria-hidden                      initial={{ opacity: 0 }}                      animate={{ opacity: 1 }}                      exit={{ opacity: 0, transition: { duration: 0.12 } }}                      transition={reduce ? { duration: 0 } : { ...PILL_SPRING, opacity: { duration: 0.15 } }}                      className={cn("absolute inset-0 bg-(--color-paper-2)", radius)}                    />                  )}                </AnimatePresence>
                {/* Active pill */}                {isActive && (                  <motion.span                    layoutId="pill"                    aria-hidden                    transition={reduce ? { duration: 0 } : PILL_SPRING}                    className={cn(                      "absolute inset-0 bg-(--color-ink)",                      "shadow-[inset_0_1px_0_rgba(255,255,255,0.14),0_1px_2px_rgba(0,0,0,0.12),0_4px_12px_-4px_rgba(0,0,0,0.25)]",                      radius,                    )}                  />                )}
                {tab.icon ? (                  <motion.span                    aria-hidden                    initial={false}                    animate={                      isActive && !reduce                        ? { scale: [0.7, 1.12, 1], rotate: [-12, 4, 0], filter: ["blur(2px)", "blur(0px)", "blur(0px)"] }                        : { scale: 1, rotate: 0, filter: "blur(0px)" }                    }                    transition={{ duration: 0.4, ease: EASE_OUT }}                    className="relative flex size-3.5 shrink-0 items-center justify-center [&_svg]:size-full [&_svg]:stroke-[1.75]"                  >                    {tab.icon}                  </motion.span>                ) : null}                <span className="relative truncate">{tab.label}</span>              </button>            );          })}        </div>      </LayoutGroup>
      <motion.div        role="tabpanel"        id={panelId}        aria-labelledby={tabId(activeTab.id)}        tabIndex={0}        initial={false}        animate={{ height }}        transition={reduce ? { duration: 0 } : HEIGHT_SPRING}        className={cn(          "relative w-full overflow-hidden rounded-[16px] bg-(--color-paper) outline-none",          "shadow-[inset_0_0_0_1px_var(--color-rule),0_1px_2px_rgba(0,0,0,0.03)]",          "focus-visible:ring-2 focus-visible:ring-(--color-focus)",        )}      >        <div ref={innerRef} className="relative">          <AnimatePresence initial={false} custom={direction} mode="popLayout">            <motion.div              key={activeTab.id}              custom={direction}              variants={panelVariants}              initial="enter"              animate="center"              exit="exit"              transition={{ duration: 0.32, ease: EASE_OUT }}              className="p-4 sm:p-5"            >              {showTitle ? (                <motion.div                  initial={reduce ? false : { opacity: 0, y: 4 }}                  animate={{ opacity: 1, y: 0 }}                  transition={{ duration: 0.3, ease: EASE_OUT, delay: 0.04 }}                  className="text-[15px] leading-snug font-semibold text-balance text-(--color-ink)"                >                  {activeTab.label}                </motion.div>              ) : null}              <motion.div                initial={reduce ? false : { opacity: 0, y: 6 }}                animate={{ opacity: 1, y: 0 }}                transition={{ duration: 0.32, ease: EASE_OUT, delay: 0.08 }}                className={cn("text-[14px] leading-relaxed text-(--color-ink-2)", showTitle && "mt-1.5")}              >                {activeTab.content}              </motion.div>            </motion.div>          </AnimatePresence>        </div>      </motion.div>    </div>  );}
/** Alias kept for the docs' name. */export const AnimatedMenu = AnimatedTabs;

Update the import paths to match your project setup.

Basic Usage

tsx
import { AnimatedTabs } from "@/components/ui/menu";import { Code2, Image, Settings } from "lucide-react";
const tabs = [  { id: "overview", label: "Overview", icon: <Code2 />, content: <div>…</div> },  { id: "media", label: "Media", icon: <Image />, content: <div>…</div> },  { id: "settings", label: "Settings", icon: <Settings />, content: <div>…</div> },];
<AnimatedTabs tabs={tabs} defaultActiveId="overview" />;

Props

PropTypeRequiredDefaultDescription
tabsTabItem[]Yes—Each tab: id, label, optional icon, and the panel content.
defaultActiveIdstring | numberNo—Initially active tab. Defaults to the first.
onChange(id: string | number) => voidNo—Fires when the active tab changes.
layout"horizontal" | "grid"No"horizontal"A single pill row, or a 2–3 column grid of tabs.
showTitlebooleanNotrueShow the active tab's label as a heading in the panel.
classNamestringNo—Class names on the outer container.

Best Practices

  1. Share one layoutId pill per instance (scoped with LayoutGroup) so multiple menus on a page never steal each other's pill.
  2. Keep blur small (≤ 6px) and brief — it should read as softness, not a loading state.
  3. Slide the panel in the direction of travel so the order of tabs has a physical meaning.
  4. Measure panel height with a ResizeObserver, not only on tab change, so late-loading content can't clip.
  5. Reduced motion keeps the pill and panel changes as instant swaps and crossfades.