{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "expandable-segmented-tabs",
  "title": "Expandable Segmented Tabs",
  "description": "A Motion-powered segmented tab list for switching modes or views, with an active tab that expands from icon-only into a label.",
  "dependencies": [
    "motion"
  ],
  "files": [
    {
      "path": "registry/base/ui/expandable-segmented-tabs.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport {\n  AnimatePresence,\n  motion,\n  useReducedMotion,\n  type Transition,\n} from \"motion/react\";\n\nimport { cn } from \"@/lib/utils\";\n\nexport type ExpandableSegmentedTabsItem = {\n  value: string;\n  label: React.ReactNode;\n  icon: React.ReactNode;\n  disabled?: boolean;\n  ariaLabel?: string;\n};\n\nexport type ExpandableSegmentedTabsProps = Omit<\n  React.ComponentProps<\"div\">,\n  \"defaultValue\" | \"onChange\"\n> & {\n  items: ExpandableSegmentedTabsItem[];\n  value?: string;\n  defaultValue?: string;\n  onValueChange?: (\n    value: string,\n    item: ExpandableSegmentedTabsItem,\n  ) => void;\n  onValueIntent?: (\n    value: string,\n    item: ExpandableSegmentedTabsItem,\n  ) => void;\n  /**\n   * Preserves the last selected value across unmount/remount for this key, so\n   * route-driven mode switches can still animate from the previous tab.\n   */\n  transitionKey?: string | null;\n  listClassName?: string;\n  itemClassName?: string;\n  activeItemClassName?: string;\n  iconClassName?: string;\n  labelClassName?: string;\n  indicatorClassName?: string;\n  \"aria-label\"?: string;\n};\n\ntype FlexTarget = {\n  flexGrow: number;\n  flexShrink: number;\n  flexBasis: string;\n};\n\nconst EXPAND_TRANSITION: Transition = {\n  duration: 0.28,\n  ease: [0.77, 0, 0.175, 1],\n};\n\nconst ACTIVE_FLEX: FlexTarget = {\n  flexGrow: 1,\n  flexShrink: 1,\n  flexBasis: \"0px\",\n};\n\nconst INACTIVE_FLEX: FlexTarget = {\n  flexGrow: 0,\n  flexShrink: 0,\n  flexBasis: \"2.25rem\",\n};\n\nconst selectedValueByTransitionKey = new Map<string, string>();\n\nfunction getEnabledItems(items: ExpandableSegmentedTabsItem[]) {\n  return items.filter((item) => !item.disabled);\n}\n\nfunction getSelectableValue(\n  items: ExpandableSegmentedTabsItem[],\n  value: string | undefined,\n) {\n  const enabledItems = getEnabledItems(items);\n\n  return enabledItems.some((item) => item.value === value)\n    ? value\n    : enabledItems[0]?.value;\n}\n\nfunction getItemByValue(\n  items: ExpandableSegmentedTabsItem[],\n  value: string,\n) {\n  return items.find((item) => item.value === value);\n}\n\nexport function ExpandableSegmentedTabs({\n  items,\n  value,\n  defaultValue,\n  onValueChange,\n  onValueIntent,\n  transitionKey,\n  className,\n  listClassName,\n  itemClassName,\n  activeItemClassName,\n  iconClassName,\n  labelClassName,\n  indicatorClassName,\n  \"aria-label\": ariaLabel = \"Options\",\n  ...props\n}: ExpandableSegmentedTabsProps) {\n  const shouldReduceMotion = useReducedMotion();\n  const isControlled = value !== undefined;\n  const itemRefs = React.useRef(new Map<string, HTMLButtonElement>());\n  const [uncontrolledValue, setUncontrolledValue] = React.useState(\n    () => getSelectableValue(items, defaultValue) ?? \"\",\n  );\n\n  const enabledItems = React.useMemo(() => getEnabledItems(items), [items]);\n  // A controlled `value` is authoritative: it renders as-is, even when it\n  // matches no enabled item (then nothing is active). Silently remapping it to\n  // the first enabled item would show a selection the parent never set, with\n  // no `onValueChange` to tell it. The fallback applies to uncontrolled state\n  // only, where this component owns the value.\n  const selectedValue = isControlled\n    ? (value ?? \"\")\n    : (getSelectableValue(items, uncontrolledValue) ?? \"\");\n  // Roving tabindex still needs exactly one tabbable item, or a controlled\n  // value that matches nothing would make the tablist keyboard-unreachable.\n  const focusableValue = enabledItems.some(\n    (item) => item.value === selectedValue,\n  )\n    ? selectedValue\n    : enabledItems[0]?.value;\n\n  const [transitionFromValue] = React.useState<string | null>(() => {\n    if (!transitionKey) return null;\n\n    const previousValue = selectedValueByTransitionKey.get(transitionKey);\n\n    return previousValue &&\n      previousValue !== selectedValue &&\n      items.some((item) => item.value === previousValue)\n      ? previousValue\n      : null;\n  });\n  const [exitIndicatorDone, setExitIndicatorDone] = React.useState(\n    transitionFromValue === null,\n  );\n\n  React.useEffect(() => {\n    if (!transitionKey || !selectedValue) return;\n\n    selectedValueByTransitionKey.set(transitionKey, selectedValue);\n  }, [selectedValue, transitionKey]);\n\n  const setSelectedValue = React.useCallback(\n    (nextValue: string) => {\n      const nextItem = getItemByValue(items, nextValue);\n\n      if (!nextItem || nextItem.disabled || nextValue === selectedValue) {\n        return;\n      }\n\n      if (!isControlled) {\n        setUncontrolledValue(nextValue);\n      }\n\n      onValueChange?.(nextValue, nextItem);\n    },\n    [isControlled, items, onValueChange, selectedValue],\n  );\n\n  const focusItem = React.useCallback((nextValue: string) => {\n    itemRefs.current.get(nextValue)?.focus();\n  }, []);\n\n  const handleIntent = React.useCallback(\n    (item: ExpandableSegmentedTabsItem) => {\n      if (item.disabled || item.value === selectedValue) return;\n\n      onValueIntent?.(item.value, item);\n    },\n    [onValueIntent, selectedValue],\n  );\n\n  const handleKeyDown = React.useCallback(\n    (\n      event: React.KeyboardEvent<HTMLButtonElement>,\n      currentValue: string,\n    ) => {\n      if (enabledItems.length === 0) return;\n\n      const currentIndex = Math.max(\n        enabledItems.findIndex((item) => item.value === currentValue),\n        0,\n      );\n      const lastIndex = enabledItems.length - 1;\n      let nextIndex = currentIndex;\n\n      if (event.key === \"ArrowRight\" || event.key === \"ArrowDown\") {\n        nextIndex = currentIndex >= lastIndex ? 0 : currentIndex + 1;\n      } else if (event.key === \"ArrowLeft\" || event.key === \"ArrowUp\") {\n        nextIndex = currentIndex <= 0 ? lastIndex : currentIndex - 1;\n      } else if (event.key === \"Home\") {\n        nextIndex = 0;\n      } else if (event.key === \"End\") {\n        nextIndex = lastIndex;\n      } else {\n        return;\n      }\n\n      event.preventDefault();\n\n      const nextItem = enabledItems[nextIndex];\n\n      if (!nextItem) return;\n\n      setSelectedValue(nextItem.value);\n      focusItem(nextItem.value);\n    },\n    [enabledItems, focusItem, setSelectedValue],\n  );\n\n  const isMountTransition =\n    transitionFromValue !== null && !exitIndicatorDone && !shouldReduceMotion;\n  const transition = shouldReduceMotion\n    ? { duration: 0 }\n    : EXPAND_TRANSITION;\n\n  return (\n    <div\n      data-slot=\"expandable-segmented-tabs\"\n      className={cn(\"flex w-full max-w-sm\", className)}\n      {...props}\n    >\n      <div\n        role=\"tablist\"\n        aria-label={ariaLabel}\n        data-slot=\"expandable-segmented-tabs-list\"\n        className={cn(\n          \"flex h-10 w-full items-center gap-1 rounded-xl border bg-muted/70 p-1\",\n          listClassName,\n        )}\n      >\n        {items.map((item) => {\n          const isActive = item.value === selectedValue;\n          const isFromValue = item.value === transitionFromValue;\n          const animateStyles = isActive ? ACTIVE_FLEX : INACTIVE_FLEX;\n          let initialStyles: FlexTarget | false = false;\n\n          if (isMountTransition) {\n            if (isActive) {\n              initialStyles = INACTIVE_FLEX;\n            } else if (isFromValue) {\n              initialStyles = ACTIVE_FLEX;\n            }\n          }\n\n          return (\n            <motion.button\n              key={item.value}\n              ref={(node) => {\n                if (node) {\n                  itemRefs.current.set(item.value, node);\n                } else {\n                  itemRefs.current.delete(item.value);\n                }\n              }}\n              type=\"button\"\n              role=\"tab\"\n              aria-selected={isActive}\n              aria-label={item.ariaLabel}\n              disabled={item.disabled}\n              tabIndex={item.value === focusableValue ? 0 : -1}\n              data-slot=\"expandable-segmented-tabs-item\"\n              data-active={isActive ? \"\" : undefined}\n              initial={initialStyles}\n              animate={animateStyles}\n              transition={transition}\n              whileTap={\n                item.disabled || shouldReduceMotion\n                  ? undefined\n                  : { scale: 0.97, transition: { duration: 0.1 } }\n              }\n              onClick={() => setSelectedValue(item.value)}\n              onFocus={() => handleIntent(item)}\n              onPointerEnter={() => handleIntent(item)}\n              onKeyDown={(event) => handleKeyDown(event, item.value)}\n              className={cn(\n                \"relative flex h-full min-w-0 items-center justify-start overflow-hidden rounded-lg px-2.5 text-sm font-medium text-muted-foreground outline-none transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-45 data-active:text-foreground\",\n                itemClassName,\n                isActive && activeItemClassName,\n              )}\n            >\n              <AnimatePresence initial={isMountTransition}>\n                {isActive ? (\n                  <motion.span\n                    key=\"indicator\"\n                    aria-hidden=\"true\"\n                    initial={\n                      shouldReduceMotion ? false : { scale: 0.96, opacity: 0 }\n                    }\n                    animate={{\n                      scale: 1,\n                      opacity: 1,\n                      transition,\n                    }}\n                    exit={{\n                      scale: shouldReduceMotion ? 1 : 0.96,\n                      opacity: 0,\n                      transition,\n                    }}\n                    className={cn(\n                      \"absolute inset-0 rounded-lg bg-background shadow-sm\",\n                      indicatorClassName,\n                    )}\n                  />\n                ) : null}\n              </AnimatePresence>\n\n              {isMountTransition && isFromValue ? (\n                <motion.span\n                  aria-hidden=\"true\"\n                  initial={{ opacity: 1 }}\n                  animate={{ opacity: 0 }}\n                  transition={transition}\n                  onAnimationComplete={() => setExitIndicatorDone(true)}\n                  className={cn(\n                    \"absolute inset-0 rounded-lg bg-background shadow-sm\",\n                    indicatorClassName,\n                  )}\n                />\n              ) : null}\n\n              <span\n                aria-hidden=\"true\"\n                className={cn(\n                  \"relative z-10 grid size-4 shrink-0 place-items-center\",\n                  iconClassName,\n                )}\n              >\n                {item.icon}\n              </span>\n              <motion.span\n                animate={\n                  isActive\n                    ? { opacity: 1, x: 0 }\n                    : { opacity: 0, x: -4 }\n                }\n                transition={transition}\n                className={cn(\n                  \"relative z-10 ml-2 whitespace-nowrap\",\n                  labelClassName,\n                )}\n              >\n                {item.label}\n              </motion.span>\n            </motion.button>\n          );\n        })}\n      </div>\n    </div>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/ui/expandable-segmented-tabs.tsx"
    }
  ],
  "meta": {
    "tags": [
      "segmented-tabs",
      "mode-switcher",
      "roving-focus",
      "arrow-keys",
      "tabs"
    ],
    "effects": [
      "active-expand",
      "active-pill",
      "tap-feedback",
      "reduced-motion"
    ]
  },
  "categories": [
    "navigation"
  ],
  "type": "registry:ui"
}