{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "author": "DesignX ", "name": "expandable-tabs", "type": "registry:component", "title": "Expandable Tabs", "description": "Icon tabs that expand to show labels.", "dependencies": [ "motion@^14.0.0" ], "registryDependencies": [ "@dx/lib-ease", "@dx/utils" ], "files": [ { "path": "components/motion/expandable-tabs.tsx", "type": "registry:component", "target": "components/motion/expandable-tabs.tsx", "content": "import {\n AnimatePresence,\n motion,\n useReducedMotion,\n type Variants,\n} from \"motion/react\";\nimport {\n useCallback,\n useEffect,\n useLayoutEffect,\n useRef,\n useState,\n type ReactNode,\n} from \"react\";\nimport { EASE_OUT } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\nexport type ExpandableTabsItem = {\n id: string;\n /** String labelshown inside the active tab and used as the button's accessible name. */\n label: string;\n icon: ReactNode;\n /** Panel shown above the bar when this tab is active. */\n content: ReactNode;\n};\n\nexport type ExpandableTabsClassNames = {\n root?: string;\n panel?: string;\n bar?: string;\n tab?: string;\n activeTab?: string;\n icon?: string;\n label?: string;\n pill?: string;\n};\n\nexport interface ExpandableTabsProps {\n items: ExpandableTabsItem[];\n /** Active tab id, or null/undefined for the closed (bar-only) state. */\n value?: string | null;\n defaultValue?: string | null;\n onValueChange?: (id: string | null) => void;\n className?: string;\n classNames?: ExpandableTabsClassNames;\n}\n\ntype Size = { width: number; height: number };\n\n// DynamicIsland-style real width/height motion, tuned tighter here so the tab\n// bar feels controlled instead of elastic.\nconst SHELL_SPRING = { type: \"spring\", duration: 0.58, bounce: 0.06 } as const;\n\n// Position-only tab layout motion keeps switching loose without stretching\n// icons or letting the label linger.\nconst TAB_CHANGE_SPRING = {\n type: \"spring\",\n duration: 0.46,\n bounce: 0.04,\n} as const;\nconst LABEL_OPEN = { type: \"spring\", duration: 0.38, bounce: 0.03 } as const;\nconst LABEL_CLOSE = { duration: 0.16, ease: EASE_OUT } as const;\n\n// Fixed bar height keeps the content panel's bottom reserve static so the open\n// height is right on the first frame. p-2 (16) + h-9 button (36).\nconst BAR_H = 52;\nconst TAB_W = 32;\nconst BAR_X = 16;\nconst BAR_GAP = 4;\nconst ROOT_BORDER = 2;\nconst ICON_W = 16;\nconst ACTIVE_LEFT_PAD = 10;\nconst ACTIVE_RIGHT_PAD = 16;\nconst LABEL_GAP = 7;\nconst PANEL_DOCK_GAP = 4;\n\n// Content is clipped above the dock so rows never pass through the icon bar.\n// It enters from slightly above instead of from the dock line.\nconst CONTENT_VARIANTS: Variants = {\n enter: { y: -8, scale: 0.98, opacity: 0, filter: \"blur(4px)\" },\n center: { y: 0, scale: 1, opacity: 1, filter: \"blur(0px)\" },\n exit: {\n y: -6,\n scale: 0.98,\n opacity: 0,\n filter: \"blur(4px)\",\n transition: { duration: 0.08, ease: EASE_OUT },\n },\n};\n\nconst REDUCED_CONTENT_VARIANTS: Variants = {\n enter: { opacity: 0, filter: \"blur(0px)\" },\n center: { opacity: 1, filter: \"blur(0px)\" },\n exit: {\n opacity: 0,\n filter: \"blur(0px)\",\n transition: { duration: 0.08, ease: EASE_OUT },\n },\n};\n\nconst CONTENT_SPRING = { type: \"spring\", duration: 0.46, bounce: 0.08 } as const;\n\nfunction sameSize(a: Size | null | undefined, b: Size | null | undefined) {\n return a?.width === b?.width && a?.height === b?.height;\n}\n\nfunction sameWidths(a: Record, b: Record) {\n const aKeys = Object.keys(a);\n const bKeys = Object.keys(b);\n\n if (aKeys.length !== bKeys.length) {\n return false;\n }\n\n return aKeys.every((key) => a[key] === b[key]);\n}\n\nfunction useContentSize() {\n const ref = useRef(null);\n const [size, setSize] = useState(null);\n\n const measure = useCallback(() => {\n const el = ref.current;\n if (!el) return;\n const next = { width: el.offsetWidth, height: el.offsetHeight };\n setSize((current) => (sameSize(current, next) ? current : next));\n }, []);\n\n useLayoutEffect(() => {\n measure();\n }, [measure]);\n\n useEffect(() => {\n const el = ref.current;\n if (!el || typeof ResizeObserver === \"undefined\") return;\n const observer = new ResizeObserver(measure);\n observer.observe(el);\n return () => observer.disconnect();\n }, [measure]);\n\n return [ref, size] as const;\n}\n\nfunction useLabelWidths(items: ExpandableTabsItem[]) {\n const refs = useRef>({});\n const [widths, setWidths] = useState>({});\n\n const setLabelMeasureRef = useCallback(\n (id: string) => (node: HTMLSpanElement | null) => {\n refs.current[id] = node;\n },\n [],\n );\n\n const measure = useCallback(() => {\n const next: Record = {};\n\n for (const item of items) {\n const node = refs.current[item.id];\n\n if (node) {\n next[item.id] = Math.ceil(node.offsetWidth);\n }\n }\n\n setWidths((current) => (sameWidths(current, next) ? current : next));\n }, [items]);\n\n useLayoutEffect(() => {\n measure();\n }, [measure]);\n\n useEffect(() => {\n if (typeof ResizeObserver === \"undefined\") {\n return;\n }\n\n const observer = new ResizeObserver(measure);\n\n for (const item of items) {\n const node = refs.current[item.id];\n\n if (node) {\n observer.observe(node);\n }\n }\n\n return () => observer.disconnect();\n }, [items, measure]);\n\n return { setLabelMeasureRef, widths };\n}\n\nexport function ExpandableTabs({\n items,\n value,\n defaultValue = null,\n onValueChange,\n className,\n classNames,\n}: ExpandableTabsProps) {\n const reduce = useReducedMotion();\n const rootRef = useRef(null);\n const [sizerRef, size] = useContentSize();\n const { setLabelMeasureRef, widths: labelWidths } = useLabelWidths(items);\n\n const controlled = value !== undefined;\n const [internal, setInternal] = useState(defaultValue);\n const activeId = controlled ? value : internal;\n const active = items.find((item) => item.id === activeId) ?? null;\n const visualActiveId = active?.id ?? null;\n\n const setActive = useCallback(\n (next: string | null) => {\n if (!controlled) setInternal(next);\n onValueChange?.(next);\n },\n [controlled, onValueChange],\n );\n\n // Outside click / Escape closesit behaves like an open menu.\n useEffect(() => {\n if (!visualActiveId) return;\n const onPointer = (e: PointerEvent) => {\n if (!rootRef.current?.contains(e.target as Node)) setActive(null);\n };\n const onKey = (e: KeyboardEvent) => {\n if (e.key === \"Escape\") setActive(null);\n };\n document.addEventListener(\"pointerdown\", onPointer);\n document.addEventListener(\"keydown\", onKey);\n return () => {\n document.removeEventListener(\"pointerdown\", onPointer);\n document.removeEventListener(\"keydown\", onKey);\n };\n }, [setActive, visualActiveId]);\n\n const closedSize = {\n width:\n items.length * TAB_W +\n Math.max(0, items.length - 1) * BAR_GAP +\n BAR_X +\n ROOT_BORDER,\n height: BAR_H + ROOT_BORDER,\n };\n const openSize = size\n ? {\n width: Math.max(size.width + ROOT_BORDER, closedSize.width),\n height: Math.max(size.height + ROOT_BORDER, closedSize.height),\n }\n : closedSize;\n const targetSize = active ? openSize : closedSize;\n\n const getActiveTabWidth = useCallback(\n (item: ExpandableTabsItem) =>\n Math.max(\n TAB_W,\n ACTIVE_LEFT_PAD +\n ICON_W +\n LABEL_GAP +\n (labelWidths[item.id] ?? 0) +\n ACTIVE_RIGHT_PAD,\n ),\n [labelWidths],\n );\n\n return (\n <>\n \n \n {items.map((item) => (\n
\n {item.content}\n
\n ))}\n \n\n \n \n {active ? (\n \n {active.content}\n \n ) : null}\n \n \n\n \n {items.map((item) => {\n const isActive = item.id === visualActiveId;\n const activeTabWidth = getActiveTabWidth(item);\n const labelWidth = labelWidths[item.id] ?? 0;\n\n return (\n // oxlint-disable-next-line jsx-a11y/interactive-supports-focus\n setActive(isActive ? null : item.id)}\n layout={reduce ? false : \"position\"}\n animate={{\n width: active && isActive ? activeTabWidth : TAB_W,\n }}\n transition={reduce ? { duration: 0 } : TAB_CHANGE_SPRING}\n className={cn(\n \"relative isolate flex h-9 min-w-8 shrink-0 items-center justify-center overflow-hidden rounded-[18px] px-2 text-sm font-medium outline-none\",\n \"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background\",\n active && isActive && \"min-w-0 justify-start pl-2.5 pr-4\",\n isActive\n ? \"text-foreground\"\n : \"text-muted-foreground hover:text-foreground\",\n classNames?.tab,\n isActive && classNames?.activeTab,\n )}\n >\n {isActive ? (\n \n ) : null}\n \n {item.icon}\n \n \n {item.label}\n \n \n );\n })}\n \n \n \n {items.map((item) => (\n \n {item.label}\n \n ))}\n \n \n );\n}\n" } ], "categories": [ "motion-kit" ], "meta": { "group": "motion", "docs": "https://dxuireact.com/docs/components/expandable-tabs" } }