"use client";
import * as React from "react";
import { motion, useReducedMotion } from "motion/react";
export interface MorphingTabItem {
/** Unique value identifying the tab. */
value: string;
/** Visible tab label. */
label: React.ReactNode;
/** Optional leading icon. */
icon?: React.ReactNode;
/** Optional panel content. When any item has content, tab panels are rendered. */
content?: React.ReactNode;
disabled?: boolean;
}
type Spring = { stiffness: number; damping: number; mass: number };
export interface MorphingTabsProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "defaultValue" | "onChange"> {
items: MorphingTabItem[];
/** Selected value (controlled). */
value?: string;
/** Initially selected value (uncontrolled). Defaults to the first enabled item. */
defaultValue?: string;
/** Called when the selection changes. */
onValueChange?: (value: string) => void;
/** Visual style of the indicator. */
variant?: "pill" | "segment" | "underline";
/** "automatic" selects on arrow-key focus; "manual" waits for Enter / Space. */
activation?: "automatic" | "manual";
/** Spring that morphs the indicator between tabs. */
spring?: Spring;
/** Accent colour (pill fill, underline, focus ring). */
accent?: string;
/** Stretch tabs to fill the available width. */
fullWidth?: boolean;
/** Accessible name of the tab list. */
label?: string;
}
const LIST: Record<NonNullable<MorphingTabsProps["variant"]>, string> = {
pill: "gap-1 rounded-full border border-zinc-900/10 bg-white p-1 dark:border-white/10 dark:bg-zinc-900",
segment:
"gap-1 rounded-[12px] border border-zinc-900/[0.07] bg-zinc-900/[0.04] p-1 dark:border-white/[0.06] dark:bg-white/[0.04]",
underline: "gap-6 border-b border-zinc-900/10 dark:border-white/10",
};
const TAB: Record<NonNullable<MorphingTabsProps["variant"]>, string> = {
pill: "h-9 rounded-full px-4 aria-selected:text-zinc-950 dark:aria-selected:text-zinc-950",
segment: "h-8 rounded-[9px] px-3.5 aria-selected:text-zinc-950 dark:aria-selected:text-white",
underline: "h-10 px-0.5 aria-selected:text-zinc-950 dark:aria-selected:text-white",
};
const INDICATOR: Record<NonNullable<MorphingTabsProps["variant"]>, string> = {
pill: "inset-0 rounded-full bg-[var(--mt-accent)]",
segment:
"inset-0 rounded-[9px] bg-white shadow-[0_1px_2px_rgba(0,0,0,0.08),0_0_0_1px_rgba(0,0,0,0.05)] dark:bg-zinc-800 dark:shadow-[0_1px_2px_rgba(0,0,0,0.5),0_0_0_1px_rgba(255,255,255,0.06)]",
underline: "inset-x-0 -bottom-px h-[2px] rounded-full bg-[var(--mt-accent)]",
};
/**
* Tabs with an indicator that morphs between selections using a shared layout animation.
* Implements the WAI-ARIA tabs pattern with roving tabindex and controlled / uncontrolled APIs.
*/
export function MorphingTabs({
items,
value,
defaultValue,
onValueChange,
variant = "pill",
activation = "automatic",
spring = { stiffness: 380, damping: 32, mass: 0.8 },
accent = "#FF7448",
fullWidth = false,
label,
className = "",
style,
...props
}: MorphingTabsProps) {
const id = React.useId();
const reduceMotion = useReducedMotion();
const tabRefs = React.useRef(new Map<string, HTMLButtonElement>());
const enabled = items.filter((item) => !item.disabled);
const [internal, setInternal] = React.useState(defaultValue ?? enabled[0]?.value);
const selected = value ?? internal;
// The tab that receives focus from Tab: the selected one, or the first enabled tab as a fallback.
const focusable = enabled.some((item) => item.value === selected) ? selected : enabled[0]?.value;
const hasPanels = items.some((item) => item.content !== undefined);
const select = (next: string) => {
if (next === selected) return;
if (value === undefined) setInternal(next);
onValueChange?.(next);
};
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
const keys = ["ArrowLeft", "ArrowRight", "Home", "End"];
if (!keys.includes(event.key) || !enabled.length) return;
event.preventDefault();
const currentValue = (event.target as HTMLElement).dataset.value;
const index = enabled.findIndex((item) => item.value === currentValue);
const last = enabled.length - 1;
const nextIndex =
event.key === "Home"
? 0
: event.key === "End"
? last
: event.key === "ArrowRight"
? (index + 1) % enabled.length
: (index - 1 + enabled.length) % enabled.length;
const next = enabled[nextIndex];
tabRefs.current.get(next.value)?.focus();
if (activation === "automatic") select(next.value);
};
const transition = reduceMotion ? { duration: 0 } : { type: "spring" as const, ...spring };
return (
<div className={className} style={{ ["--mt-accent" as string]: accent, ...style }} {...props}>
<div
role="tablist"
aria-label={label}
aria-orientation="horizontal"
onKeyDown={handleKeyDown}
className={`relative ${fullWidth ? "flex w-full" : "inline-flex"} items-center ${LIST[variant]}`}
>
{items.map((item, index) => {
const isSelected = item.value === selected;
return (
<button
key={item.value}
ref={(node) => {
if (node) tabRefs.current.set(item.value, node);
else tabRefs.current.delete(item.value);
}}
type="button"
role="tab"
id={`${id}-tab-${index}`}
data-value={item.value}
aria-selected={isSelected}
aria-controls={hasPanels ? `${id}-panel-${index}` : undefined}
tabIndex={item.value === focusable ? 0 : -1}
disabled={item.disabled}
onClick={() => select(item.value)}
className={`relative inline-flex select-none items-center justify-center gap-2 whitespace-nowrap text-sm font-medium tracking-tight text-zinc-500 transition-colors duration-200 hover:text-zinc-900 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--mt-accent)] disabled:cursor-not-allowed disabled:opacity-40 dark:text-zinc-400 dark:hover:text-zinc-100 ${
fullWidth ? "flex-1" : ""
} ${TAB[variant]}`}
>
{isSelected && (
<motion.span
aria-hidden="true"
// useId keeps the layoutId unique, so several tab sets on one page never share an indicator.
layoutId={`${id}-indicator`}
transition={transition}
className={`absolute ${INDICATOR[variant]}`}
/>
)}
{item.icon && (
<span aria-hidden="true" className="relative z-10 inline-flex [&_svg]:h-4 [&_svg]:w-4">
{item.icon}
</span>
)}
<span className="relative z-10">{item.label}</span>
</button>
);
})}
</div>
{hasPanels &&
items.map((item, index) => {
const isSelected = item.value === selected;
return (
<div
key={item.value}
role="tabpanel"
id={`${id}-panel-${index}`}
aria-labelledby={`${id}-tab-${index}`}
hidden={!isSelected}
tabIndex={0}
className="mt-4 rounded-xl focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--mt-accent)]"
>
{isSelected && (
<motion.div
initial={reduceMotion ? false : { opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, ease: [0.16, 1, 0.3, 1] }}
>
{item.content}
</motion.div>
)}
</div>
);
})}
</div>
);
}