{"slug":"expandable-action-bar","name":"Expandable Action Bar","description":"Compact icon actions that expand into labeled controls on hover or focus with shared layout motion.","category":"motion","source_url":"https://beui.saura3h.xyz/r/expandable-action-bar/raw","detail_url":"https://beui.saura3h.xyz/r/expandable-action-bar","raw_url":"https://beui.saura3h.xyz/r/expandable-action-bar/raw","page_url":"https://beui.saura3h.xyz/components/motion/expandable-action-bar","dependencies":["lucide-react","motion","react"],"internal":["@/components/motion/expandable-action-bar","@/lib/utils"],"files":[{"path":"components/motion/expandable-action-bar.tsx","type":"component","content":"\"use client\";\n\nimport { LayoutGroup, motion, useReducedMotion, type Transition } from \"motion/react\";\nimport {\n  useCallback,\n  useEffect,\n  useId,\n  useMemo,\n  useRef,\n  useState,\n  type FocusEvent,\n  type MouseEvent,\n  type ReactNode,\n} from \"react\";\nimport { cn } from \"@/lib/utils\";\n\nexport type ExpandableActionBarSize = \"sm\" | \"md\";\n\nexport type ExpandableActionBarItem = {\n  id: string;\n  label: ReactNode;\n  icon: ReactNode;\n  onClick?: () => void;\n  disabled?: boolean;\n  active?: boolean;\n  badge?: ReactNode;\n  shortcut?: ReactNode;\n};\n\nexport type ExpandableActionBarClassNames = {\n  root?: string;\n  track?: string;\n  item?: string;\n  activeItem?: string;\n  icon?: string;\n  label?: string;\n  badge?: string;\n  shortcut?: string;\n};\n\nexport interface ExpandableActionBarProps {\n  items: ExpandableActionBarItem[];\n  expanded?: boolean;\n  defaultExpanded?: boolean;\n  onExpandedChange?: (expanded: boolean) => void;\n  activeId?: string;\n  onAction?: (item: ExpandableActionBarItem) => void;\n  size?: ExpandableActionBarSize;\n  expandOnHover?: boolean;\n  expandOnFocus?: boolean;\n  collapseDelay?: number;\n  className?: string;\n  classNames?: ExpandableActionBarClassNames;\n  renderItem?: (item: ExpandableActionBarItem, state: { expanded: boolean; active: boolean }) => ReactNode;\n}\n\nconst ITEM_TRANSITION: Transition = {\n  type: \"spring\",\n  stiffness: 460,\n  damping: 34,\n  mass: 0.62,\n};\n\nconst LABEL_TRANSITION: Transition = {\n  type: \"spring\",\n  stiffness: 380,\n  damping: 32,\n  mass: 0.7,\n};\n\nconst SIZE_CLASS: Record<ExpandableActionBarSize, string> = {\n  sm: \"min-h-9 gap-1 p-1 text-xs\",\n  md: \"min-h-11 gap-1.5 p-1.5 text-sm\",\n};\n\nconst ITEM_SIZE_CLASS: Record<ExpandableActionBarSize, string> = {\n  sm: \"h-7 min-w-7 px-1.5\",\n  md: \"h-8 min-w-8 px-2\",\n};\n\nconst ICON_SIZE_CLASS: Record<ExpandableActionBarSize, string> = {\n  sm: \"h-3.5 w-3.5\",\n  md: \"h-4 w-4\",\n};\n\nfunction useControllableExpanded({\n  expanded,\n  defaultExpanded,\n  onExpandedChange,\n}: {\n  expanded?: boolean;\n  defaultExpanded?: boolean;\n  onExpandedChange?: (expanded: boolean) => void;\n}) {\n  const [internalExpanded, setInternalExpanded] = useState(defaultExpanded ?? false);\n  const isControlled = expanded !== undefined;\n  const value = expanded ?? internalExpanded;\n\n  const setValue = useCallback(\n    (next: boolean) => {\n      if (!isControlled) setInternalExpanded(next);\n      onExpandedChange?.(next);\n    },\n    [isControlled, onExpandedChange],\n  );\n\n  return [value, setValue] as const;\n}\n\nexport function ExpandableActionBar({\n  items,\n  expanded,\n  defaultExpanded = false,\n  onExpandedChange,\n  activeId,\n  onAction,\n  size = \"md\",\n  expandOnHover = true,\n  expandOnFocus = true,\n  collapseDelay = 90,\n  className,\n  classNames,\n  renderItem,\n}: ExpandableActionBarProps) {\n  const reduce = useReducedMotion();\n  const layoutId = useId();\n  const [isExpanded, setIsExpanded] = useControllableExpanded({\n    expanded,\n    defaultExpanded,\n    onExpandedChange,\n  });\n  const [hoveredId, setHoveredId] = useState<string | null>(null);\n  const collapseTimer = useRef<number | null>(null);\n\n  const clearCollapseTimer = useCallback(() => {\n    if (collapseTimer.current) window.clearTimeout(collapseTimer.current);\n    collapseTimer.current = null;\n  }, []);\n\n  const open = useCallback(() => {\n    clearCollapseTimer();\n    setIsExpanded(true);\n  }, [clearCollapseTimer, setIsExpanded]);\n\n  const close = useCallback(() => {\n    clearCollapseTimer();\n    const timer = window.setTimeout(() => {\n      setIsExpanded(false);\n      setHoveredId(null);\n    }, collapseDelay);\n    collapseTimer.current = timer;\n  }, [clearCollapseTimer, collapseDelay, setIsExpanded]);\n\n  useEffect(() => clearCollapseTimer, [clearCollapseTimer]);\n\n  const onRootMouseEnter = () => {\n    if (expandOnHover) open();\n  };\n\n  const onRootMouseLeave = () => {\n    setHoveredId(null);\n    if (expandOnHover) close();\n  };\n\n  const onRootFocus = () => {\n    if (expandOnFocus) open();\n  };\n\n  const onRootBlur = (event: FocusEvent<HTMLDivElement>) => {\n    if (!event.currentTarget.contains(event.relatedTarget as Node) && expandOnFocus) {\n      close();\n    }\n  };\n\n  const activeItemId = activeId ?? items.find((item) => item.active)?.id;\n  const highlightId = hoveredId ?? activeItemId;\n\n  return (\n    <LayoutGroup id={layoutId}>\n      <motion.div\n        layout=\"size\"\n        onMouseEnter={onRootMouseEnter}\n        onMouseLeave={onRootMouseLeave}\n        onFocus={onRootFocus}\n        onBlur={onRootBlur}\n        transition={ITEM_TRANSITION}\n        className={cn(\"inline-flex\", classNames?.root, className)}\n      >\n        <motion.div\n          layout=\"size\"\n          className={cn(\n            \"relative inline-flex items-center overflow-hidden rounded-full border border-(--color-border) bg-(--color-bg-elev)/90 shadow-[0_18px_50px_-30px_rgb(0_0_0/0.55)] backdrop-blur-xl\",\n            SIZE_CLASS[size],\n            classNames?.track,\n          )}\n          transition={ITEM_TRANSITION}\n        >\n          {items.map((item) => {\n            const isActive = item.active || activeId === item.id;\n            const isHighlighted = highlightId === item.id;\n\n            return (\n              <motion.button\n                key={item.id}\n                layout=\"position\"\n                type=\"button\"\n                disabled={item.disabled}\n                title={typeof item.label === \"string\" ? item.label : undefined}\n                onMouseEnter={() => {\n                  clearCollapseTimer();\n                  setHoveredId(item.id);\n                }}\n                onClick={(event: MouseEvent<HTMLButtonElement>) => {\n                  event.currentTarget.blur();\n                  item.onClick?.();\n                  onAction?.(item);\n                }}\n                whileTap={reduce || item.disabled ? undefined : { scale: 0.96 }}\n                transition={ITEM_TRANSITION}\n                className={cn(\n                  \"relative isolate inline-flex items-center justify-center overflow-hidden rounded-full font-medium text-(--color-fg-muted) outline-none transition-[color,background-color] duration-150 ease-out\",\n                  \"focus-visible:text-(--color-fg) disabled:pointer-events-none disabled:opacity-40\",\n                  isHighlighted && \"text-(--color-fg)\",\n                  ITEM_SIZE_CLASS[size],\n                  classNames?.item,\n                  isActive && classNames?.activeItem,\n                )}\n              >\n                {isHighlighted ? (\n                  <motion.span\n                    layoutId=\"action-bar-highlight\"\n                    className=\"absolute inset-0 -z-10 rounded-full bg-(--color-fg)/[0.07]\"\n                    transition={ITEM_TRANSITION}\n                  />\n                ) : null}\n\n                {renderItem ? (\n                  renderItem(item, { expanded: isExpanded, active: isActive })\n                ) : (\n                  <>\n                    <span\n                      className={cn(\n                        \"inline-flex shrink-0 items-center justify-center\",\n                        ICON_SIZE_CLASS[size],\n                        classNames?.icon,\n                      )}\n                    >\n                      {item.icon}\n                    </span>\n\n                    <motion.span\n                      aria-hidden={!isExpanded}\n                      animate={\n                        reduce\n                          ? {\n                              width: isExpanded ? \"auto\" : 0,\n                              opacity: isExpanded ? 1 : 0,\n                              marginLeft: isExpanded ? 8 : 0,\n                            }\n                          : {\n                              width: isExpanded ? \"auto\" : 0,\n                              opacity: isExpanded ? 1 : 0,\n                              x: isExpanded ? 0 : -4,\n                              marginLeft: isExpanded ? 8 : 0,\n                              filter: isExpanded ? \"blur(0px)\" : \"blur(3px)\",\n                            }\n                      }\n                      transition={LABEL_TRANSITION}\n                      className={cn(\n                        \"inline-block overflow-hidden whitespace-nowrap\",\n                        classNames?.label,\n                      )}\n                    >\n                      {item.label}\n                    </motion.span>\n\n                    {item.shortcut ? (\n                      <motion.span\n                        aria-hidden={!isExpanded}\n                        animate={{\n                          width: isExpanded ? \"auto\" : 0,\n                          opacity: isExpanded ? 1 : 0,\n                          marginLeft: isExpanded ? 4 : 0,\n                        }}\n                        transition={LABEL_TRANSITION}\n                        className={cn(\n                          \"hidden overflow-hidden whitespace-nowrap text-[10px] text-(--color-fg-muted) sm:inline-block\",\n                          classNames?.shortcut,\n                        )}\n                      >\n                        {item.shortcut}\n                      </motion.span>\n                    ) : null}\n\n                    {item.badge ? (\n                      <span\n                        className={cn(\n                          \"ml-0.5 inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-(--color-danger) px-1 text-[10px] leading-none text-white\",\n                          !isExpanded && \"absolute right-0.5 top-0.5\",\n                          classNames?.badge,\n                        )}\n                      >\n                        {item.badge}\n                      </span>\n                    ) : null}\n                  </>\n                )}\n              </motion.button>\n            );\n          })}\n        </motion.div>\n      </motion.div>\n    </LayoutGroup>\n  );\n}\n\nexport function useExpandableActionBar(items: ExpandableActionBarItem[]) {\n  const [expanded, setExpanded] = useState(false);\n  const [activeId, setActiveId] = useState(items[0]?.id);\n\n  const activeItem = useMemo(\n    () => items.find((item) => item.id === activeId),\n    [activeId, items],\n  );\n\n  return useMemo(\n    () => ({ expanded, setExpanded, activeId, setActiveId, activeItem }),\n    [activeId, activeItem, expanded],\n  );\n}\n"},{"path":"components/previews/motion/expandable-action-bar.preview.tsx","type":"preview","content":"\"use client\";\n\nimport {\n  Archive,\n  Bell,\n  Check,\n  Copy,\n  Download,\n  Send,\n  Settings,\n} from \"lucide-react\";\nimport { useMemo, useState } from \"react\";\nimport {\n  ExpandableActionBar,\n  type ExpandableActionBarItem,\n} from \"@/components/motion/expandable-action-bar\";\n\nconst ACTIONS: ExpandableActionBarItem[] = [\n  {\n    id: \"send\",\n    label: \"Send\",\n    icon: <Send className=\"h-4 w-4\" />,\n    shortcut: \"S\",\n  },\n  {\n    id: \"copy\",\n    label: \"Copy\",\n    icon: <Copy className=\"h-4 w-4\" />,\n    shortcut: \"C\",\n  },\n  {\n    id: \"download\",\n    label: \"Export\",\n    icon: <Download className=\"h-4 w-4\" />,\n    shortcut: \"E\",\n  },\n  {\n    id: \"archive\",\n    label: \"Archive\",\n    icon: <Archive className=\"h-4 w-4\" />,\n  },\n  {\n    id: \"alerts\",\n    label: \"Alerts\",\n    icon: <Bell className=\"h-4 w-4\" />,\n    badge: \"3\",\n  },\n  {\n    id: \"settings\",\n    label: \"Settings\",\n    icon: <Settings className=\"h-4 w-4\" />,\n  },\n];\n\nexport function ExpandableActionBarPreview() {\n  const [expanded, setExpanded] = useState(false);\n  const [activeId, setActiveId] = useState(\"send\");\n\n  const items = useMemo(\n    () =>\n      ACTIONS.map((item) => ({\n        ...item,\n        active: item.id === activeId,\n      })),\n    [activeId],\n  );\n\n  return (\n    <div className=\"flex min-h-72 w-full flex-col items-center justify-center gap-6\">\n      <div className=\"flex min-h-24 items-center justify-center\">\n        <ExpandableActionBar\n          items={items}\n          expanded={expanded}\n          onExpandedChange={setExpanded}\n          activeId={activeId}\n          onAction={(item) => setActiveId(item.id)}\n        />\n      </div>\n\n      <div className=\"flex flex-wrap items-center justify-center gap-2\">\n        <button\n          type=\"button\"\n          onClick={() => setExpanded((current) => !current)}\n          className=\"inline-flex h-9 items-center gap-2 rounded-full border border-(--color-border) bg-(--color-bg-elev) px-4 text-xs font-medium text-(--color-fg) transition-colors press hover:border-(--color-border-strong)\"\n        >\n          {expanded ? <Check className=\"h-3.5 w-3.5\" /> : null}\n          {expanded ? \"Expanded\" : \"Expand\"}\n        </button>\n      </div>\n    </div>\n  );\n}\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"}]}