{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"expandable-tabs","type":"registry:component","title":"Expandable Tabs","description":"A row of icon tabs where the selected one expands to reveal its label, sliding a shared background pill to match.","author":"Ryan","dependencies":["clsx","motion","tailwind-merge"],"registryDependencies":[],"files":[{"path":"components/motion/expandable-tabs.tsx","type":"registry:component","target":"@components/motion/expandable-tabs.tsx","content":"\"use client\";\n// easeui.dev/components/motion/expandable-tabs\n\nimport { motion } from \"motion/react\";\nimport { type KeyboardEvent, type ReactNode, useState } from \"react\";\nimport { SPRING_LAYOUT } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface ExpandableTab {\n  id: string;\n  label: string;\n  icon: ReactNode;\n}\n\nexport interface ExpandableTabsProps {\n  tabs: ExpandableTab[];\n  /** Controlled selected tab id. */\n  value?: string;\n  /** Starting selection when uncontrolled. Defaults to the first tab. */\n  defaultValue?: string;\n  onChange?: (id: string) => void;\n  className?: string;\n}\n\nconst NAV_KEYS = new Set([\"ArrowRight\", \"ArrowLeft\", \"Home\", \"End\"]);\n\n/**\n * A row of icon tabs where the selected one expands to reveal its label,\n * sliding a shared background pill to match.\n */\nexport function ExpandableTabs({ tabs, value, defaultValue, onChange, className }: ExpandableTabsProps) {\n  const [uncontrolled, setUncontrolled] = useState(defaultValue ?? tabs[0]?.id);\n  const isControlled = value !== undefined;\n  const current = isControlled ? value : uncontrolled;\n\n  const select = (id: string) => {\n    if (!isControlled) setUncontrolled(id);\n    onChange?.(id);\n  };\n\n  // Arrow keys, Home, and End move between tabs and select them.\n  const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {\n    if (!NAV_KEYS.has(event.key)) return;\n    const buttons = Array.from(event.currentTarget.querySelectorAll<HTMLButtonElement>('[role=\"tab\"]'));\n    const index = buttons.indexOf(document.activeElement as HTMLButtonElement);\n    if (index < 0) return;\n    event.preventDefault();\n    const next =\n      event.key === \"Home\"\n        ? 0\n        : event.key === \"End\"\n          ? buttons.length - 1\n          : (index + (event.key === \"ArrowRight\" ? 1 : -1) + buttons.length) % buttons.length;\n    buttons[next].focus();\n    buttons[next].click();\n  };\n\n  return (\n    <div\n      role=\"tablist\"\n      onKeyDown={onKeyDown}\n      className={cn(\n        \"inline-flex items-center gap-1 rounded-full bg-card p-1 shadow-[0_0_0_1px_var(--border)]\",\n        className,\n      )}\n    >\n      {tabs.map((tab) => {\n        const active = tab.id === current;\n        return (\n          <button\n            key={tab.id}\n            type=\"button\"\n            role=\"tab\"\n            aria-selected={active}\n            tabIndex={active ? 0 : -1}\n            onClick={() => select(tab.id)}\n            className=\"relative flex h-9 shrink-0 items-center gap-1.5 rounded-full px-3 text-sm font-medium outline-none transition-colors duration-150 focus-visible:ring-2 focus-visible:ring-foreground/40\"\n          >\n            {active ? (\n              <motion.span\n                layoutId=\"expandable-tabs-indicator\"\n                transition={SPRING_LAYOUT}\n                className=\"absolute inset-0 rounded-full bg-background shadow-[0_0_0_1px_var(--border)]\"\n              />\n            ) : null}\n            <span\n              className={cn(\n                \"relative z-10 flex shrink-0 items-center\",\n                active ? \"text-foreground\" : \"text-muted-foreground\",\n              )}\n            >\n              {tab.icon}\n            </span>\n            <motion.span\n              initial={false}\n              animate={{ width: active ? \"auto\" : 0, opacity: active ? 1 : 0 }}\n              transition={SPRING_LAYOUT}\n              className=\"relative z-10 overflow-hidden whitespace-nowrap text-foreground\"\n            >\n              {tab.label}\n            </motion.span>\n          </button>\n        );\n      })}\n    </div>\n  );\n}\n"},{"path":"lib/ease.ts","type":"registry:lib","target":"@lib/ease.ts","content":"// Shared motion tokens. Micro-interactions run 100 to 150ms, standard UI\n// 150 to 250ms, and panels up to 300ms.\n// Easing curves mirror the CSS custom properties in globals.css.\n\n/** ease-out-quint. Fast start that settles quickly. Entrances, exits, feedback. */\nexport const EASE_OUT = [0.23, 1, 0.32, 1] as const;\n/** ease-in-out-cubic. Elements already on screen moving to a new spot. */\nexport const EASE_IN_OUT = [0.645, 0.045, 0.355, 1] as const;\n/** Sheet and drawer glide. */\nexport const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;\n\n/** CSS string form of EASE_OUT for inline style transitions. */\nexport const EASE_OUT_CSS = \"cubic-bezier(0.23, 1, 0.32, 1)\";\n\n// Springs are described by duration and bounce, which is easier to reason about\n// than stiffness and damping. Bounce stays at zero for product UI.\n\n/** Press feedback on buttons and other tappable surfaces. */\nexport const SPRING_PRESS = {\n  type: \"spring\",\n  duration: 0.15,\n  bounce: 0,\n} as const;\n\n/** Content swaps, label and icon slots trading places inside a control. */\nexport const SPRING_SWAP = {\n  type: \"spring\",\n  duration: 0.2,\n  bounce: 0,\n} as const;\n\n/** Overlay panel entrances, modals and sheets summoned by pointer. */\nexport const SPRING_PANEL = {\n  type: \"spring\",\n  duration: 0.25,\n  bounce: 0,\n} as const;\n\n/** Shared-layout glides, pills and indicators moving between positions. */\nexport const SPRING_LAYOUT = {\n  type: \"spring\",\n  duration: 0.22,\n  bounce: 0,\n} as const;\n\n/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt). */\nexport const SPRING_MOUSE = {\n  stiffness: 320,\n  damping: 26,\n  mass: 0.3,\n} as const;\n\n/** Dragged handles and fills (sliders). Critically damped `useSpring` config,\n * so the value follows the pointer closely and never rebounds off an end. */\nexport const SPRING_GLIDE = {\n  stiffness: 700,\n  damping: 50,\n  mass: 0.5,\n} as const;\n"},{"path":"lib/utils.ts","type":"registry:lib","target":"@lib/utils.ts","content":"import { clsx, type ClassValue } from \"clsx\"\nimport { twMerge } from \"tailwind-merge\"\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs))\n}\n"}]}