{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "tabs", "title": "Tabs (Base UI)", "description": "Pill, segment, and underline tab navigation with animated selection indicators, proximity hover, and spring animations. Base UI flavor.", "dependencies": [ "framer-motion", "@base-ui/react" ], "registryDependencies": [ "https://zeron-ui.vercel.app/r/surfaces.json", "https://zeron-ui.vercel.app/r/badge.json", "utils", "https://zeron-ui.vercel.app/r/springs.json", "https://zeron-ui.vercel.app/r/shape-context.json", "https://zeron-ui.vercel.app/r/icon-context.json", "https://zeron-ui.vercel.app/r/use-proximity-hover.json" ], "files": [ { "path": "src/components/ui/tabs.tsx", "content": "\"use client\";\n\nimport {\n useRef,\n useState,\n useCallback,\n useEffect,\n useLayoutEffect,\n createContext,\n useContext,\n forwardRef,\n Children,\n cloneElement,\n isValidElement,\n type ComponentPropsWithoutRef,\n type ReactNode,\n} from \"react\";\nimport { Tabs as TabsPrimitive } from \"@base-ui/react/tabs\";\nimport { motion, AnimatePresence } from \"framer-motion\";\nimport type { IconComponent } from \"@/lib/icon-context\";\nimport { cn } from \"@/lib/utils\";\nimport { spring } from \"@/lib/springs\";\nimport { useShape } from \"@/lib/shape-context\";\nimport { useProximityHover } from \"@/hooks/use-proximity-hover\";\nimport { Badge, type BadgeProps } from \"@/components/ui/badge\";\n\n/* ─────────────────────── Contexts ─────────────────────── */\n\ninterface TabsValueOrderContextValue {\n valueOrder: string[];\n setValueOrder: (order: string[]) => void;\n selectedValue: string | undefined;\n variant: TabsVariant;\n}\n\nconst TabsValueOrderContext = createContext(null);\n\ninterface TabsListContextValue {\n registerTab: (index: number, value: string, el: HTMLElement | null) => void;\n hoveredIndex: number | null;\n selectedValue: string | undefined;\n setOptimisticIdx: (index: number) => void;\n variant: TabsVariant;\n labelVisibility: TabLabelVisibility;\n}\n\nconst TabsListContext = createContext(null);\n\nfunction useTabsList() {\n const ctx = useContext(TabsListContext);\n if (!ctx) throw new Error(\"TabItem must be used within a TabsList\");\n return ctx;\n}\n\nexport type TabsVariant = \"pill\" | \"segment\" | \"underline\";\nexport type TabLabelVisibility = \"all\" | \"active\";\n\n/**\n * A compact Badge configuration for a TabItem. Pass a number or text for the\n * default badge, or use the complete Badge API for color and status badges.\n */\nexport type TabBadgeProps = Omit & {\n children: ReactNode;\n};\n\nexport type TabBadge = ReactNode | TabBadgeProps;\n\nfunction isTabBadgeProps(badge: TabBadge): badge is TabBadgeProps {\n return typeof badge === \"object\" && badge !== null && !isValidElement(badge) && \"children\" in badge;\n}\n\n/* ─────────────────────── Tabs (Root) ─────────────────────── */\n\ninterface TabsProps\n extends Omit<\n ComponentPropsWithoutRef,\n \"onValueChange\" | \"value\" | \"defaultValue\" | \"onSelect\"\n > {\n value?: string;\n onValueChange?: (value: string) => void;\n selectedIndex?: number;\n onSelect?: (index: number) => void;\n defaultValue?: string;\n /** Visual treatment. Pill preserves the original Tabs appearance. */\n variant?: TabsVariant;\n}\n\nconst Tabs = forwardRef(\n (\n {\n value,\n onValueChange,\n selectedIndex,\n onSelect,\n defaultValue,\n variant = \"pill\",\n children,\n ...props\n },\n ref\n ) => {\n const [valueOrder, setValueOrder] = useState([]);\n const [uncontrolledValue, setUncontrolledValue] = useState(\n defaultValue\n );\n const updateValueOrder = useCallback((order: string[]) => {\n setValueOrder((current) => {\n if (\n current.length === order.length &&\n current.every((v, i) => v === order[i])\n ) {\n return current;\n }\n return order;\n });\n }, []);\n\n // Resolve value: explicit value > selectedIndex lookup > uncontrolled state.\n // Uncontrolled with no defaultValue falls back to the first tab so the\n // FF layer's selectedValue matches what the primitive shows.\n const resolvedValue =\n value ??\n (selectedIndex != null\n ? valueOrder[selectedIndex]\n : uncontrolledValue ?? valueOrder[0]);\n\n // Base UI passes (value, eventDetails); we only need value.\n const handleValueChange = useCallback(\n (newValue: unknown) => {\n const v = newValue as string;\n if (value === undefined && selectedIndex == null) {\n setUncontrolledValue(v);\n }\n onValueChange?.(v);\n if (onSelect) {\n const idx = valueOrder.indexOf(v);\n if (idx !== -1) onSelect(idx);\n }\n },\n [onValueChange, onSelect, valueOrder, value, selectedIndex]\n );\n\n return (\n \n {/*\n Always controlled: Base UI's useControlled logs a dev warning when\n value flips undefined → defined. valueOrder is empty on the first\n commit, so fall back to an empty-string sentinel — TabsList's\n layout effect populates valueOrder pre-paint, so the corrected\n value lands before anything is visible.\n */}\n \n {children}\n \n \n );\n }\n);\n\nTabs.displayName = \"Tabs\";\n\n/* ─────────────────────── TabsList ─────────────────────── */\n\ninterface TabsListProps\n extends ComponentPropsWithoutRef {\n /** Controls icon-tab labels for every Tabs variant. Defaults to showing all labels. */\n labelVisibility?: TabLabelVisibility;\n /** @deprecated Use labelVisibility=\"active\" instead. */\n activeLabel?: boolean;\n /** Whether arrow-key focus immediately selects a tab. */\n activationMode?: \"automatic\" | \"manual\";\n}\n\nconst TabsList = forwardRef(\n ({ children, className, labelVisibility, activeLabel, activationMode, activateOnFocus, ...props }, ref) => {\n const containerRef = useRef(null);\n const isMouseInside = useRef(false);\n const shape = useShape();\n const valueOrderCtx = useContext(TabsValueOrderContext);\n const variant = valueOrderCtx?.variant ?? \"pill\";\n const resolvedLabelVisibility = labelVisibility ?? (activeLabel ? \"active\" : \"all\");\n const [optimisticIdx, setOptimisticIdx] = useState(null);\n\n const values = Children.toArray(children)\n .filter(isValidElement)\n .map((child) => (child.props as { value?: string }).value)\n .filter((v): v is string => typeof v === \"string\");\n const valueOrderKey = values.join(\",\");\n const setValueOrder = valueOrderCtx?.setValueOrder;\n\n useLayoutEffect(() => {\n setValueOrder?.(values);\n }, [setValueOrder, valueOrderKey]);\n\n const {\n activeIndex: hoveredIndex,\n setActiveIndex: setHoveredIndex,\n itemRects,\n handlers,\n registerItem,\n measureItems,\n } = useProximityHover(containerRef, { axis: \"x\" });\n const tabElementsRef = useRef(new Map());\n\n const registerTab = useCallback(\n (index: number, _value: string, el: HTMLElement | null) => {\n registerItem(index, el);\n if (el) tabElementsRef.current.set(index, el);\n else tabElementsRef.current.delete(index);\n },\n [registerItem]\n );\n\n useEffect(() => {\n measureItems();\n }, [measureItems, children]);\n\n // Active-only labels change an individual trigger's width after selection.\n // Keep every variant's indicator aligned as labels expand/collapse.\n useEffect(() => {\n if (tabElementsRef.current.size === 0 || typeof ResizeObserver === \"undefined\") return;\n const observer = new ResizeObserver(() => measureItems());\n tabElementsRef.current.forEach((element) => observer.observe(element));\n return () => observer.disconnect();\n }, [measureItems, children]);\n\n const handleMouseMove = useCallback(\n (e: React.MouseEvent) => {\n isMouseInside.current = true;\n handlers.onMouseMove(e);\n },\n [handlers]\n );\n\n const handleMouseLeave = useCallback(() => {\n isMouseInside.current = false;\n handlers.onMouseLeave();\n }, [handlers]);\n\n const [focusedIndex, setFocusedIndex] = useState(null);\n const selectedValue = valueOrderCtx?.selectedValue;\n const selectedIdx =\n selectedValue !== undefined ? values.indexOf(selectedValue) : -1;\n\n useEffect(() => {\n setOptimisticIdx(selectedIdx >= 0 ? selectedIdx : null);\n }, [selectedIdx]);\n\n const activeSelectedIdx = optimisticIdx;\n const selectedRect =\n activeSelectedIdx !== null ? itemRects[activeSelectedIdx] : null;\n const hoverRect = hoveredIndex !== null ? itemRects[hoveredIndex] : null;\n const focusRect = focusedIndex !== null ? itemRects[focusedIndex] : null;\n const isHoveringSelected = hoveredIndex === activeSelectedIdx;\n const isHovering = hoveredIndex !== null && !isHoveringSelected;\n\n const indexedChildren = Children.map(children, (child, i) => {\n // Skip plain DOM elements — injecting _index into e.g. a
\n // triggers React's unknown-prop warning.\n if (isValidElement(child) && typeof child.type !== \"string\") {\n return cloneElement(child, { _index: i } as Record);\n }\n return child;\n });\n\n return (\n \n {\n (\n containerRef as React.MutableRefObject\n ).current = node;\n if (typeof ref === \"function\") ref(node);\n else if (ref)\n (\n ref as React.MutableRefObject\n ).current = node;\n }}\n onMouseMove={handleMouseMove}\n onMouseLeave={handleMouseLeave}\n onFocus={(e) => {\n const trigger = (e.target as HTMLElement).closest('[role=\"tab\"]');\n if (!trigger) return;\n const indexAttr = trigger.getAttribute(\"data-proximity-index\");\n if (indexAttr != null) {\n const idx = Number(indexAttr);\n setHoveredIndex(idx);\n setFocusedIndex(\n (e.target as HTMLElement).matches(\":focus-visible\") ? idx : null\n );\n }\n }}\n onBlur={(e) => {\n if (containerRef.current?.contains(e.relatedTarget as Node)) return;\n setFocusedIndex(null);\n if (isMouseInside.current) return;\n setHoveredIndex(null);\n }}\n className={cn(\n variant === \"pill\" && [\n \"relative inline-flex items-center gap-0.5 p-1 select-none bg-muted\",\n shape.container,\n ],\n variant === \"segment\" &&\n \"relative flex items-center gap-0.5 select-none overflow-x-auto max-w-[calc(100%_+_8px)] scrollbar-hide -mx-1 px-1 -my-1 py-1\",\n variant === \"underline\" &&\n \"relative flex items-center gap-0.5 select-none overflow-x-auto max-w-[calc(100%_+_8px)] scrollbar-hide -mx-1 px-1 -my-1 border-b border-border\",\n className\n )}\n {...props}\n >\n {/* Active indicator */}\n {selectedRect && (\n \n )}\n\n {/* Hover indicator */}\n \n {variant !== \"underline\" && hoverRect && !isHoveringSelected && selectedRect && (\n \n )}\n \n\n {/* Focus ring */}\n \n {focusRect && (\n \n )}\n \n\n {indexedChildren}\n \n \n );\n }\n);\n\nTabsList.displayName = \"TabsList\";\n\n/* ─────────────────────── TabItem ─────────────────────── */\n\ninterface TabItemProps\n extends ComponentPropsWithoutRef {\n value: string;\n icon?: IconComponent;\n label: string;\n /** A number, text, Badge element, or Badge props shown after the label. */\n badge?: TabBadge;\n /** @internal Auto-assigned by TabsList. */\n _index?: number;\n}\n\nconst TabItem = forwardRef(\n ({ value, icon: Icon, label, badge, _index = 0, className, onClick, ...props }, ref) => {\n const internalRef = useRef(null);\n const shape = useShape();\n const {\n registerTab,\n hoveredIndex,\n selectedValue,\n setOptimisticIdx,\n variant,\n labelVisibility,\n } = useTabsList();\n\n useEffect(() => {\n registerTab(_index, value, internalRef.current);\n return () => registerTab(_index, value, null);\n }, [_index, value, registerTab]);\n\n const isSelected = selectedValue === value;\n const isActive = hoveredIndex === _index || isSelected;\n const collapseLabel = labelVisibility === \"active\" && !!Icon;\n const showLabel = !collapseLabel || isSelected;\n const badgeContent = badge == null\n ? null\n : isTabBadgeProps(badge)\n ? \n : {badge};\n\n const labelContent = (\n \n \n {label}\n \n \n {label}\n \n \n );\n\n return (\n {\n setOptimisticIdx(_index);\n onClick?.(e);\n }}\n ref={(node) => {\n (\n internalRef as React.MutableRefObject\n ).current = node as HTMLButtonElement | null;\n if (typeof ref === \"function\") ref(node as HTMLButtonElement);\n else if (ref)\n (\n ref as React.MutableRefObject\n ).current = node as HTMLButtonElement | null;\n }}\n value={value}\n data-proximity-index={_index}\n aria-label={collapseLabel && !showLabel ? label : undefined}\n className={cn(\n // Fixed heights keep every tab variant aligned and easy to target.\n \"relative z-content flex items-center cursor-pointer bg-transparent border-none outline-none\",\n badge == null ? \"px-3\" : \"pl-3 pr-1.5\",\n variant === \"pill\" && [\n \"h-control-sm\",\n collapseLabel ? \"gap-0\" : \"gap-2\",\n ],\n variant === \"segment\" && [\n \"h-control-sm\",\n collapseLabel ? \"gap-0\" : \"gap-2\",\n shape.bg,\n ],\n variant === \"underline\" && [\n \"h-control-md\",\n collapseLabel ? \"gap-0\" : \"gap-2\",\n ],\n className\n )}\n {...props}\n >\n {Icon && (\n \n )}\n {collapseLabel ? (\n \n {showLabel && (\n \n {labelContent}\n \n )}\n \n ) : (\n labelContent\n )}\n {badgeContent}\n \n );\n }\n);\n\nTabItem.displayName = \"TabItem\";\n\n/* ─────────────────────── TabPanel ─────────────────────── */\n\ninterface TabPanelProps\n extends ComponentPropsWithoutRef {\n value: string;\n}\n\nconst TabPanel = forwardRef(\n ({ className, ...props }, ref) => {\n return (\n \n );\n }\n);\n\nTabPanel.displayName = \"TabPanel\";\n\nexport { Tabs, TabsList, TabItem, TabPanel };\nexport type { TabsProps, TabsListProps, TabItemProps, TabPanelProps };\n", "type": "registry:ui", "target": "components/ui/tabs.tsx" } ], "type": "registry:ui" }