{"slug":"expandable-tabs","name":"Expandable Tabs","description":"Icon tab bar where the active tab expands to a labelled pill, with a panel above that morphs height and slides content direction-aware on switch.","category":"blocks","source_url":"https://beui.saura3h.xyz/r/expandable-tabs/raw","detail_url":"https://beui.saura3h.xyz/r/expandable-tabs","raw_url":"https://beui.saura3h.xyz/r/expandable-tabs/raw","page_url":"https://beui.saura3h.xyz/components/blocks/expandable-tabs","dependencies":["clsx","lucide-react","motion","react","tailwind-merge"],"internal":["@/components/motion/expandable-tabs","@/lib/ease","@/lib/utils"],"files":[{"path":"components/motion/expandable-tabs.tsx","type":"component","content":"\"use client\";\n\nimport {\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 label — shown 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    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<string, number>, b: Record<string, number>) {\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<HTMLDivElement | null>(null);\n  const [size, setSize] = useState<Size | null>(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<Record<string, HTMLSpanElement | null>>({});\n  const [widths, setWidths] = useState<Record<string, number>>({});\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<string, number> = {};\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<HTMLDivElement>(null);\n  const [sizerRef, size] = useContentSize();\n  const { setLabelMeasureRef, widths: labelWidths } = useLabelWidths(items);\n\n  const controlled = value !== undefined;\n  const [internal, setInternal] = useState<string | null>(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 closes — it 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      <motion.div\n        ref={rootRef}\n        initial={false}\n        animate={\n          targetSize\n            ? { width: targetSize.width, height: targetSize.height }\n            : undefined\n        }\n        transition={reduce ? { duration: 0 } : SHELL_SPRING}\n        style={{ transformOrigin: \"bottom center\" }}\n        className={cn(\n          \"relative overflow-hidden rounded-[26px] border border-border bg-card\",\n          className,\n          classNames?.root,\n        )}\n      >\n        <div\n          ref={sizerRef}\n          aria-hidden\n          className={cn(\n            \"pointer-events-none invisible absolute left-0 top-0 grid w-max px-2 pt-2\",\n            classNames?.panel,\n          )}\n          style={{ paddingBottom: BAR_H + PANEL_DOCK_GAP }}\n        >\n          {items.map((item) => (\n            <div key={item.id} className=\"col-start-1 row-start-1 w-max\">\n              {item.content}\n            </div>\n          ))}\n        </div>\n\n        <div\n          className={cn(\n            \"absolute left-0 right-0 top-0 z-10 overflow-hidden px-2 pt-2\",\n            classNames?.panel,\n          )}\n          style={{ bottom: BAR_H + PANEL_DOCK_GAP }}\n        >\n          <AnimatePresence mode=\"popLayout\" initial={false}>\n            {active ? (\n              <motion.div\n                key={active.id}\n                variants={reduce ? undefined : CONTENT_VARIANTS}\n                initial={reduce ? { opacity: 0 } : \"enter\"}\n                animate={reduce ? { opacity: 1 } : \"center\"}\n                exit={reduce ? { opacity: 0 } : \"exit\"}\n                transition={\n                  reduce ? { duration: 0.15, ease: EASE_OUT } : CONTENT_SPRING\n                }\n                className=\"w-max\"\n                style={{\n                  transformOrigin: \"top center\",\n                  willChange: \"transform, opacity, filter\",\n                }}\n              >\n                {active.content}\n              </motion.div>\n            ) : null}\n          </AnimatePresence>\n        </div>\n\n        <div\n          role=\"tablist\"\n          aria-label=\"Navigation tabs\"\n          aria-orientation=\"horizontal\"\n          className={cn(\n            \"absolute bottom-0 left-0 z-20 flex w-full items-center justify-between gap-1 p-2\",\n            classNames?.bar,\n          )}\n          style={{ height: BAR_H }}\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              <motion.button\n                key={item.id}\n                type=\"button\"\n                role=\"tab\"\n                aria-selected={isActive}\n                aria-label={item.label}\n                onClick={() => 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                  <span\n                    className={cn(\n                      \"absolute inset-0 -z-10 rounded-[18px] bg-foreground/10\",\n                      classNames?.pill,\n                    )}\n                  />\n                ) : null}\n                <span\n                  className={cn(\n                    \"grid shrink-0 place-items-center\",\n                    classNames?.icon,\n                  )}\n                >\n                  {item.icon}\n                </span>\n                <motion.span\n                  aria-hidden\n                  initial={false}\n                  animate={\n                    reduce\n                      ? {\n                          width: isActive ? labelWidth : 0,\n                          opacity: isActive ? 1 : 0,\n                          marginLeft: isActive ? LABEL_GAP : 0,\n                        }\n                      : {\n                          width: isActive ? labelWidth : 0,\n                          opacity: isActive ? 1 : 0,\n                          marginLeft: isActive ? LABEL_GAP : 0,\n                          filter: isActive ? \"blur(0px)\" : \"blur(3px)\",\n                        }\n                  }\n                  transition={\n                    reduce\n                      ? { duration: 0 }\n                      : isActive\n                        ? LABEL_OPEN\n                        : LABEL_CLOSE\n                  }\n                  className={cn(\n                    \"inline-block overflow-hidden whitespace-nowrap\",\n                    classNames?.label,\n                  )}\n                >\n                  {item.label}\n                </motion.span>\n              </motion.button>\n            );\n          })}\n        </div>\n      </motion.div>\n      <div\n        aria-hidden=\"true\"\n        className=\"pointer-events-none fixed left-0 top-0 -z-10 flex opacity-0\"\n      >\n        {items.map((item) => (\n          <span\n            className={cn(\n              \"whitespace-nowrap text-sm font-medium leading-none\",\n              classNames?.label,\n            )}\n            key={item.id}\n            ref={setLabelMeasureRef(item.id)}\n          >\n            {item.label}\n          </span>\n        ))}\n      </div>\n    </>\n  );\n}\n"},{"path":"lib/ease.ts","type":"util","content":"// Shared motion tokens. Easing curves mirror the CSS custom properties in\n// globals.css; springs are the canonical physics used across components.\n// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.\n\nexport const EASE_OUT = [0.16, 1, 0.3, 1] as const;\nexport const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;\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.16, 1, 0.3, 1)\";\n\n/** Press feedback on buttons and other tappable surfaces. */\nexport const SPRING_PRESS = {\n  type: \"spring\",\n  stiffness: 500,\n  damping: 30,\n  mass: 0.6,\n} as const;\n\n/** Content swaps — label/icon slots trading places inside a control. */\nexport const SPRING_SWAP = {\n  type: \"spring\",\n  stiffness: 460,\n  damping: 30,\n  mass: 0.55,\n} as const;\n\n/** Overlay panel entrances — modals and sheets summoned by pointer. */\nexport const SPRING_PANEL = {\n  type: \"spring\",\n  stiffness: 420,\n  damping: 40,\n  mass: 0.5,\n} as const;\n\n/** Shared-layout glides — pills, indicators and panels morphing between positions. */\nexport const SPRING_LAYOUT = {\n  type: \"spring\",\n  stiffness: 360,\n  damping: 32,\n  mass: 0.6,\n} as const;\n\n/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */\nexport const SPRING_MOUSE = {\n  stiffness: 200,\n  damping: 15,\n  mass: 0.3,\n} as const;\n"},{"path":"lib/utils.ts","type":"util","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"},{"path":"components/previews/blocks/expandable-tabs.preview.tsx","type":"preview","content":"\"use client\";\n\nimport {\n  BadgeCheck,\n  Brush,\n  CalendarClock,\n  ChartSpline,\n  ChevronRight,\n  ClipboardCheck,\n  CloudUpload,\n  FileText,\n  Gauge,\n  GitBranch,\n  Images,\n  Inbox,\n  MessageCircle,\n  Megaphone,\n  PackageOpen,\n  RefreshCw,\n  Rocket,\n  Siren,\n  SwatchBook,\n  UploadCloud,\n  Users,\n  Webhook,\n  Workflow,\n  type LucideIcon,\n} from \"lucide-react\";\nimport { ExpandableTabs } from \"@/components/motion/expandable-tabs\";\n\nfunction Row({ icon: Icon, label }: { icon: LucideIcon; label: string }) {\n  return (\n    <button\n      type=\"button\"\n      className=\"flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-left text-sm font-medium text-foreground transition-colors hover:bg-muted\"\n    >\n      <Icon className=\"h-4 w-4 text-muted-foreground\" />\n      <span className=\"flex-1\">{label}</span>\n      <ChevronRight className=\"h-4 w-4 text-muted-foreground\" />\n    </button>\n  );\n}\n\nfunction Menu({ rows }: { rows: { icon: LucideIcon; label: string }[] }) {\n  return (\n    <div className=\"flex w-[17.125rem] flex-col gap-0.5\">\n      {rows.map((r) => (\n        <Row key={r.label} icon={r.icon} label={r.label} />\n      ))}\n    </div>\n  );\n}\n\nexport function ExpandableTabsPreview() {\n  return (\n    <div className=\"flex min-h-88 w-full items-end justify-center\">\n      <ExpandableTabs\n        items={[\n          {\n            id: \"launch\",\n            label: \"Launch\",\n            icon: <Rocket className=\"h-4 w-4\" />,\n            content: (\n              <Menu\n                rows={[\n                  { icon: FileText, label: \"Release Brief\" },\n                  { icon: ClipboardCheck, label: \"Launch Checklist\" },\n                  { icon: Megaphone, label: \"Campaign Notes\" },\n                  { icon: CalendarClock, label: \"Rollout Calendar\" },\n                  { icon: CloudUpload, label: \"Ship Build\" },\n                ]}\n              />\n            ),\n          },\n          {\n            id: \"inbox\",\n            label: \"Inbox\",\n            icon: <Inbox className=\"h-4 w-4\" />,\n            content: (\n              <Menu\n                rows={[\n                  { icon: MessageCircle, label: \"Client Feedback\" },\n                  { icon: Users, label: \"Team Requests\" },\n                  { icon: BadgeCheck, label: \"Approval Notes\" },\n                ]}\n              />\n            ),\n          },\n          {\n            id: \"flows\",\n            label: \"Flows\",\n            icon: <Workflow className=\"h-4 w-4\" />,\n            content: (\n              <Menu\n                rows={[\n                  { icon: GitBranch, label: \"Trigger Map\" },\n                  { icon: Webhook, label: \"Webhook Runs\" },\n                  { icon: RefreshCw, label: \"Retry Queue\" },\n                ]}\n              />\n            ),\n          },\n          {\n            id: \"assets\",\n            label: \"Assets\",\n            icon: <PackageOpen className=\"h-4 w-4\" />,\n            content: (\n              <Menu\n                rows={[\n                  { icon: SwatchBook, label: \"Brand Kit\" },\n                  { icon: Images, label: \"Mockup Library\" },\n                  { icon: Brush, label: \"Design Tokens\" },\n                  { icon: UploadCloud, label: \"Export Queue\" },\n                ]}\n              />\n            ),\n          },\n          {\n            id: \"status\",\n            label: \"Status\",\n            icon: <ChartSpline className=\"h-4 w-4\" />,\n            content: (\n              <Menu\n                rows={[\n                  { icon: Gauge, label: \"Activation\" },\n                  { icon: ChartSpline, label: \"Conversion\" },\n                  { icon: Siren, label: \"Incidents\" },\n                ]}\n              />\n            ),\n          },\n        ]}\n      />\n    </div>\n  );\n}\n"}]}