Overview

Segmented tabs use transitions.dev tabs sliding (t-tabs / t-tabs-pill). The content panel uses card resize (t-resize) so height morphs when the active section changes, with a short opacity + blur enter.

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, {  useCallback,  useEffect,  useLayoutEffect,  useRef,  useState,} from "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;};
export function AnimatedTabs({  tabs,  defaultActiveId,  layout = "horizontal",  className = "",}: AnimatedTabsProps) {  const [activeId, setActiveId] = useState(defaultActiveId ?? tabs[0]?.id);  const barRef = useRef<HTMLDivElement>(null);  const pillRef = useRef<HTMLSpanElement>(null);  const panelInnerRef = useRef<HTMLDivElement>(null);  const [panelHeight, setPanelHeight] = useState<number | undefined>(undefined);  const animatePill = useRef(false);
  const moveTo = useCallback((tab: HTMLElement | null, animate: boolean) => {    const pill = pillRef.current;    const bar = barRef.current;    if (!pill || !tab || !bar) return;
    // offsetLeft/Top are relative to offsetParent; keep pill positioned to the bar    const next = {      transform: `translate(${tab.offsetLeft}px, ${tab.offsetTop}px)`,      width: `${tab.offsetWidth}px`,      height: `${tab.offsetHeight}px`,    };
    if (!animate) {      const prev = pill.style.transition;      pill.style.transition = "none";      pill.style.transform = next.transform;      pill.style.width = next.width;      pill.style.height = next.height;      void pill.offsetWidth;      pill.style.transition = prev;    } else {      pill.style.transform = next.transform;      pill.style.width = next.width;      pill.style.height = next.height;    }  }, []);
  const activeTabEl = useCallback(() => {    const bar = barRef.current;    if (!bar) return null;    return (      bar.querySelector<HTMLElement>('.t-tab[aria-selected="true"]') ??      bar.querySelector<HTMLElement>(".t-tab")    );  }, []);
  useLayoutEffect(() => {    moveTo(activeTabEl(), animatePill.current);    animatePill.current = false;  }, [activeId, tabs, layout, moveTo, activeTabEl]);
  useLayoutEffect(() => {    const node = panelInnerRef.current;    if (!node) return;    setPanelHeight(node.getBoundingClientRect().height);  }, [activeId, tabs]);
  useEffect(() => {    const onResize = () => {      moveTo(activeTabEl(), false);      const node = panelInnerRef.current;      if (node) setPanelHeight(node.getBoundingClientRect().height);    };    window.addEventListener("resize", onResize);    return () => window.removeEventListener("resize", onResize);  }, [moveTo, activeTabEl]);
  const setTab = (id: string | number) => {    if (id === activeId) return;    animatePill.current = true;    setActiveId(id);  };
  const activeTab = tabs.find((t) => t.id === activeId);
  return (    <div      className={cn(        "flex w-full flex-col gap-3 rounded-xl border border-(--color-rule)",        "bg-(--color-paper-2) p-3 sm:p-4",        className,      )}    >      <div        ref={barRef}        role="tablist"        aria-label="Sections"        className={cn(          "t-tabs",          layout === "grid"            ? "!grid w-full grid-cols-2 gap-1 sm:grid-cols-3"            : "w-fit max-w-full flex-wrap",        )}        style={          {            borderRadius: layout === "grid" ? "12px" : "999px",            padding: 3,            gap: 3,            "--tabs-bar-bg": "var(--color-paper)",            "--tabs-pill-bg": "var(--color-ink)",            "--tabs-text-muted": "var(--color-ink-muted)",            // Used for inactive hover (snippet). Active label uses paper via inline style.            "--tabs-text-active": "var(--color-ink)",            "--tabs-dur": "220ms",            "--tabs-ease": "cubic-bezier(0.22, 1, 0.36, 1)",            background: "var(--color-paper)",            boxShadow: "inset 0 0 0 1px var(--color-rule)",          } as React.CSSProperties        }      >        <span          ref={pillRef}          className="t-tabs-pill"          style={{            top: 0,            left: 0,            height: 0,            borderRadius: layout === "grid" ? "10px" : "999px",            background: "var(--tabs-pill-bg)",            boxShadow: "0 1px 2px rgba(0,0,0,0.06)",            transition:              "transform var(--tabs-dur) var(--tabs-ease), width var(--tabs-dur) var(--tabs-ease), height var(--tabs-dur) var(--tabs-ease)",          }}          aria-hidden="true"        />        {tabs.map((tab) => {          const isActive = tab.id === activeId;          return (            <button              key={tab.id}              type="button"              role="tab"              aria-selected={isActive}              aria-controls={`panel-${tab.id}`}              id={`tab-${tab.id}`}              onClick={() => setTab(tab.id)}              className={cn(                "t-tab relative z-1 inline-flex items-center justify-center gap-1.5",                "rounded-full px-3 text-[13px] font-medium outline-none",                "transition-colors duration-[var(--tabs-dur)] ease-[var(--tabs-ease)]",                "focus-visible:ring-2 focus-visible:ring-(--color-focus) focus-visible:ring-offset-2 focus-visible:ring-offset-(--color-paper-2)",                "active:scale-[0.98]",              )}              style={{                height: 36,                background: "transparent",                // Paper on ink pill when active (inline beats snippet hover/active color)                color: isActive                  ? "var(--color-paper)"                  : "var(--tabs-text-muted)",                borderRadius: layout === "grid" ? 10 : 999,              }}            >              {tab.icon ? (                <span className="flex size-3.5 shrink-0 items-center justify-center [&_svg]:size-full [&_svg]:stroke-[1.75]">                  {tab.icon}                </span>              ) : null}              <span className="truncate">{tab.label}</span>            </button>          );        })}      </div>
      <div        role="tabpanel"        id={`panel-${activeId}`}        aria-labelledby={`tab-${activeId}`}        className={cn(          "t-resize w-full overflow-hidden rounded-lg border border-(--color-rule)",          "bg-(--color-paper)",        )}        style={{          height: panelHeight,          // Prefer resize tokens; keep under ~300ms for UI          ["--resize-dur" as string]: "240ms",          ["--resize-ease" as string]: "cubic-bezier(0.22, 1, 0.36, 1)",        }}      >        <div ref={panelInnerRef} className="p-4 sm:p-5">          <div            key={activeId}            className="menu-panel-enter"            style={              {                ["--menu-enter-dur" as string]: "200ms",              } as React.CSSProperties            }          >            <h3 className="text-balance text-[15px] font-semibold leading-snug text-(--color-ink)">              {activeTab?.label}            </h3>            <div className="mt-1.5 text-sm leading-relaxed text-(--color-ink-2)">              {activeTab?.content}            </div>          </div>        </div>      </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