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