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, { useId, useRef, useState } from "react";import { motion, AnimatePresence, 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;  layout?: "horizontal" | "grid";  className?: string;};
const PILL_SPRING = { type: "spring" as const, bounce: 0, duration: 0.28 };const PANEL = { duration: 0.22, ease: [0.23, 1, 0.32, 1] as const };
export function AnimatedTabs({  tabs,  defaultActiveId,  layout = "grid",  className = "",}: AnimatedTabsProps) {  const reduce = useReducedMotion();  const layoutId = useId();  const [activeId, setActiveId] = useState(defaultActiveId ?? tabs[0]?.id);  const prevId = useRef(activeId);  const direction =    tabs.findIndex((t) => t.id === activeId) >=    tabs.findIndex((t) => t.id === prevId.current)      ? 1      : -1;
  const setTab = (id: string | number) => {    prevId.current = activeId;    setActiveId(id);  };
  const activeTab = tabs.find((t) => t.id === activeId);
  return (    <div      className={cn(        "flex flex-col gap-6 rounded-lg border border-(--color-rule)",        "bg-(--color-paper-2) p-4",        className,      )}    >      <div        className={cn(          "w-fit rounded-md border border-(--color-rule) bg-(--color-paper) p-1",          layout === "grid"            ? "grid grid-cols-2 gap-1 sm:grid-cols-4"            : "flex flex-wrap gap-1",        )}      >        {tabs.map((tab) => {          const isActive = tab.id === activeId;          return (            <motion.button              key={tab.id}              type="button"              onClick={() => setTab(tab.id)}              className={cn(                "relative min-h-10 rounded-[calc(var(--radius-md)-2px)] px-3 py-2 text-sm outline-none",                "transition-colors duration-100 ease-[cubic-bezier(0.23,1,0.32,1)]",                "focus-visible:ring-2 focus-visible:ring-(--color-focus)",                isActive                  ? "font-medium text-(--color-ink)"                  : "text-(--color-ink-muted) hover:text-(--color-ink-2)",              )}              whileTap={reduce ? undefined : { scale: 0.96 }}              aria-pressed={isActive}            >              {isActive && (                <motion.span                  layoutId={`menu-pill-${layoutId}`}                  className={cn(                    "absolute inset-0 z-0 rounded-[calc(var(--radius-md)-2px)]",                    "border border-(--color-rule) bg-(--color-paper-2)",                    "shadow-[0_1px_2px_rgba(0,0,0,0.04)]",                  )}                  transition={reduce ? { duration: 0.12 } : PILL_SPRING}                />              )}              <span className="relative z-1 flex items-center justify-center gap-1.5">                {tab.icon && (                  <span className="flex size-4 items-center justify-center [&_svg]:size-full [&_svg]:stroke-[1.5]">                    {tab.icon}                  </span>                )}                {tab.label}              </span>            </motion.button>          );        })}      </div>
      <div        className={cn(          "w-full max-w-md rounded-md border border-(--color-rule)",          "bg-(--color-paper) p-5",        )}      >        <AnimatePresence mode="wait" initial={false} custom={direction}>          <motion.div            key={String(activeId)}            custom={direction}            initial={              reduce                ? { opacity: 0 }                : { opacity: 0, x: direction * 16, filter: "blur(4px)" }            }            animate={{ opacity: 1, x: 0, filter: "blur(0px)" }}            exit={              reduce                ? { opacity: 0 }                : {                    opacity: 0,                    x: direction * -12,                    filter: "blur(4px)",                    transition: { duration: 0.16 },                  }            }            transition={reduce ? { duration: 0.12 } : PANEL}            className="space-y-2"          >            <h3 className="text-balance text-lg font-semibold text-(--color-ink)">              {activeTab?.label}            </h3>            <div className="text-sm leading-relaxed text-(--color-ink-2)">              {activeTab?.content}            </div>          </motion.div>        </AnimatePresence>      </div>    </div>  );}

Update the import paths to match your project setup.

Basic Usage

tsx
import { AnimatedMenu } from "@/components/snippets/menu/Demo";
const tabs = [  {    id: 1,    label: "Home",    icon: <IconHome className="h-4 w-4" />,    content: <div>Home</div>,  },  {    id: 2,    label: "Projects",    icon: <IconDeviceLaptop className="h-4 w-4" />,    content: <div>Projects</div>,  },  {    id: 3,    label: "About",    icon: <IconUser className="h-4 w-4" />,    content: <div>About</div>,  },  {    id: 4,    label: "Contact",    icon: <IconMail className="h-4 w-4" />,    content: <div>Contact</div>,  },];
<AnimatedMenu tabs={tabs} />;

Props

PropTypeRequiredDefaultDescription
classNamestringNoThe className of the tab to display
idnumberNoThe id of the tab to display
labelstringNoThe label of the tab to display
iconReact.ReactNodeNoThe icon of the tab to display
contentReact.ReactNodeNoThe content of the tab to display

Examples

tsx
<AnimatedMenu tabs={tabs} />

tsx
<AnimatedMenu  tabs={tabs}  className="bg-linear-to-r from-purple-500 to-pink-500"/>

Best Practices

  1. Pill spring stays critically damped (bounce: 0, ≤280ms) — no hover scale on tabs.

  2. Panel enter/exit shares a direction axis; reduced motion is opacity-only.

  3. Use AnimatePresence initial={false} so first paint stays quiet.

  4. Prefer paper/ink tokens over generic gray utilities.

  5. Lazy Loading: Load content only when menu items are selected

  6. Animation Optimization: Use will-change CSS property for better performance

  7. Memoization: Memoize menu items to prevent unnecessary re-renders

  8. Debouncing: Debounce rapid menu changes to improve performance