{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "expanding-button-group",
  "type": "registry:ui",
  "title": "Expanding button group",
  "description": "Icon buttons in a compact group: the one you point at or focus grows to reveal its label while its neighbours slide aside, and an action confirms in place.",
  "categories": [
    "actions"
  ],
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://irisui.dev/r/foundation.json",
    "https://irisui.dev/r/motion-tokens.json",
    "https://irisui.dev/r/utils.json"
  ],
  "files": [
    {
      "path": "src/registry/components/expanding-button-group/expanding-button-group.tsx",
      "content": "\"use client\";\n\nimport { useCallback, useEffect, useLayoutEffect, useRef, useState } from \"react\";\nimport type { CSSProperties, FocusEvent, KeyboardEvent, PointerEvent, ReactNode } from \"react\";\nimport { AnimatePresence, animate, motion, useMotionValue, useReducedMotion } from \"motion/react\";\nimport type { TargetAndTransition, Transition, Variants } from \"motion/react\";\nimport { motionTokens } from \"../lib/motion-tokens\";\nimport { cn } from \"../lib/utils\";\n\nexport type ExpandingButtonGroupSize = \"sm\" | \"md\";\nexport type ExpandingButtonGroupTone = \"neutral\" | \"danger\";\n\nexport interface ExpandingButtonGroupItem {\n  /** Stable key for the action. Also the value passed to onAction. */\n  id: string;\n  /** Visible label once the button expands, and its accessible name at all times. Use a short verb, such as \"Archive\". */\n  label: string;\n  /** Icon shown while collapsed. A 16px lucide icon fits; it is sized by the group. */\n  icon: ReactNode;\n  /** Runs the action. Return false, or reject, to skip the confirmation. A promise keeps the button busy until it settles. */\n  onSelect?: () => void | boolean | Promise<void | boolean>;\n  /** Past tense word shown in place after the action, such as \"Archived\". The icon becomes a check for confirmDuration. Omit for actions that open something. */\n  doneLabel?: string;\n  /** \"danger\" colors the expanded state with the danger token, for destructive actions such as Delete. */\n  tone?: ExpandingButtonGroupTone;\n  /** Dimmed and inert. Stays focusable and still reveals its label, so people can learn what it would do. */\n  disabled?: boolean;\n}\n\nexport interface ExpandingButtonGroupProps {\n  items: readonly ExpandingButtonGroupItem[];\n  /** Accessible name of the toolbar, such as \"Message actions\". */\n  label: string;\n  size?: ExpandingButtonGroupSize;\n  /** Id of the action whose label shows at rest, usually the primary one. Defaults to the first enabled action: one action is always\n   *  expanded. Pointing at or focusing another moves the expansion there, and it returns here when the pointer and focus leave. */\n  defaultExpanded?: string | null;\n  /** Called with the item id whenever an action runs, before its own onSelect. */\n  onAction?: (id: string) => void;\n  /** How long the confirmation stays, in milliseconds. */\n  confirmDuration?: number;\n  className?: string;\n  style?: CSSProperties;\n}\n\n/** Geometry per size. The icon keeps the same left inset collapsed and expanded, so it never moves while the label is revealed. */\n/* A flat pill on the surface. Geometry comes from the component as --ebg-* variables, so the measured widths and the CSS agree. */\nconst groupClass =\n  \"relative isolate inline-flex max-w-full flex-none items-center gap-0.5 rounded-button border border-line bg-surface p-(--ebg-pad) [--ebg-end:14px] [--ebg-gap:7px] [--ebg-h:36px] [--ebg-icon:16px] [--ebg-pad:3px] [--ebg-start:10px]\";\n/* The box never scales: its width is the animation, so a press answers with the icon and with colour. The label overflows to the\n   right of the icon and the clipped width reveals it. min-width keeps the spring's settle from pinching a collapsed button. A\n   confirmation carries its meaning on the check, with a faint tint behind the whole button. Before the first measurement (the\n   server render), the expanded action takes its natural width, which is exactly the width the spring later targets, so hydration\n   never moves anything. */\nconst itemClass =\n  \"group/item relative inline-flex h-(--ebg-h) min-w-(--ebg-h) flex-none cursor-pointer touch-manipulation items-center justify-start gap-(--ebg-gap) overflow-clip rounded-button border-0 bg-transparent pl-(--ebg-start) leading-none font-medium tracking-body whitespace-nowrap text-ink-2 [-webkit-tap-highlight-color:transparent] [transition:color_var(--duration-fast)_var(--ease-standard),background-color_var(--duration-standard)_var(--ease-standard)] aria-busy:cursor-progress aria-disabled:cursor-not-allowed aria-disabled:text-ink-3 aria-disabled:opacity-50 data-expanded:bg-hover data-expanded:text-ink data-[tone=danger]:data-expanded:not-aria-disabled:bg-[color-mix(in_oklab,var(--red)_10%,var(--surface))] data-[tone=danger]:data-expanded:not-aria-disabled:text-red data-[state=done]:not-data-[tone=danger]:bg-[color-mix(in_oklab,var(--green)_10%,var(--surface))] aria-disabled:data-expanded:bg-hover aria-disabled:data-expanded:text-ink-2 data-unmeasured:data-expanded:w-auto! data-unmeasured:data-expanded:pe-(--ebg-end) motion-reduce:[transition:color_var(--duration-instant)_linear,background-color_var(--duration-instant)_linear] dark:data-[tone=danger]:data-expanded:not-aria-disabled:bg-[color-mix(in_oklab,var(--red)_14%,var(--surface))] dark:data-[state=done]:not-data-[tone=danger]:bg-[color-mix(in_oklab,var(--green)_14%,var(--surface))]\";\nconst iconClass =\n  \"relative grid size-(--ebg-icon) flex-none place-items-center [transition:transform_320ms_var(--ease-spring)] group-aria-busy/item:animate-expanding-button-group-busy group-[:active:not([aria-disabled=true])]/item:[transform:scale(.86)] group-[:active:not([aria-disabled=true])]/item:duration-[90ms] group-[:active:not([aria-disabled=true])]/item:ease-standard group-[[data-state=done]:not([data-tone=danger])]/item:text-green motion-reduce:[transform:none] motion-reduce:transition-none motion-reduce:group-aria-busy/item:animate-none motion-reduce:group-aria-busy/item:opacity-60 motion-reduce:group-[:active:not([aria-disabled=true])]/item:[transform:none] motion-reduce:group-[:active:not([aria-disabled=true])]/item:transition-none\";\n\nconst metrics = {\n  sm: { height: 28, icon: 14, start: 7, gap: 5, end: 11 },\n  md: { height: 36, icon: 16, start: 10, gap: 7, end: 14 },\n} as const;\n\n/** Pixels the pointer must travel into a neighbouring action before the expansion moves to it. */\nconst hysteresis = 4;\n\nconst enter = [...motionTokens.ease.enter] as [number, number, number, number];\nconst standard = [...motionTokens.ease.standard] as [number, number, number, number];\n\n/** The label slides out from behind the icon as the width opens, and tucks back a little faster than it came. */\nconst labelVariants: Variants = {\n  shown: {\n    opacity: 1,\n    x: 0,\n    filter: \"blur(0px)\",\n    transition: { duration: motionTokens.duration.standard, ease: enter, delay: 0.04 },\n  },\n  hidden: {\n    opacity: 0,\n    x: -6,\n    filter: `blur(${motionTokens.blur.subtle}px)`,\n    transition: { duration: motionTokens.duration.instant, ease: standard },\n  },\n};\nconst reducedLabelVariants: Variants = {\n  shown: { opacity: 1, x: 0, filter: \"blur(0px)\", transition: { duration: motionTokens.duration.instant } },\n  hidden: { opacity: 0, x: 0, filter: \"blur(0px)\", transition: { duration: motionTokens.duration.instant } },\n};\nconst rest: TargetAndTransition = { opacity: 1, y: 0, scale: 1, filter: \"blur(0px)\" };\nconst textIn: TargetAndTransition = { opacity: 0, y: \"0.5em\", filter: `blur(${motionTokens.blur.soft}px)` };\nconst textOut: TargetAndTransition = {\n  opacity: 0,\n  y: \"-0.4em\",\n  filter: `blur(${motionTokens.blur.subtle}px)`,\n  transition: { duration: motionTokens.duration.fast, ease: standard },\n};\nconst iconIn: TargetAndTransition = { opacity: 0, scale: 0.55, filter: `blur(${motionTokens.blur.subtle}px)` };\nconst iconOut: TargetAndTransition = {\n  ...iconIn,\n  transition: { duration: motionTokens.duration.fast, ease: standard },\n};\nconst fadeIn: TargetAndTransition = { ...rest, opacity: 0 };\nconst fadeOut: TargetAndTransition = { opacity: 0, transition: { duration: motionTokens.duration.instant } };\nconst textEnter: Transition = { duration: motionTokens.duration.standard, ease: enter };\n/** Scale rides the spring; opacity and blur tween so blur never overshoots below zero. */\nconst iconEnter: Transition = {\n  ...motionTokens.spring.snappy,\n  opacity: { duration: motionTokens.duration.fast, ease: enter },\n  filter: { duration: motionTokens.duration.fast, ease: enter },\n};\nconst instant: Transition = { duration: motionTokens.duration.instant };\n\n/** The confirmation tick draws itself from its short stroke. */\nfunction DrawnCheck({ reduced }: { reduced: boolean }) {\n  return (\n    <svg\n      viewBox=\"0 0 24 24\"\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeWidth={2}\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n    >\n      <motion.path\n        d=\"M4 12.5l5 5L20 6.5\"\n        initial={reduced ? false : { pathLength: 0, opacity: 0 }}\n        animate={{ pathLength: 1, opacity: 1 }}\n        transition={{\n          pathLength: { duration: motionTokens.duration.standard, ease: enter, delay: 0.06 },\n          opacity: { duration: 0.05, delay: 0.06 },\n        }}\n      />\n    </svg>\n  );\n}\n\ninterface ItemProps {\n  item: ExpandingButtonGroupItem;\n  size: ExpandingButtonGroupSize;\n  expanded: boolean;\n  done: boolean;\n  busy: boolean;\n  tabStop: boolean;\n  reduced: boolean;\n  onPointerDown: (item: ExpandingButtonGroupItem, event: PointerEvent<HTMLButtonElement>) => void;\n  onClick: (item: ExpandingButtonGroupItem) => void;\n  onFocus: (item: ExpandingButtonGroupItem, event: FocusEvent<HTMLButtonElement>) => void;\n}\n\nfunction Item({ item, size, expanded, done, busy, tabStop, reduced, onPointerDown, onClick, onFocus }: ItemProps) {\n  const m = metrics[size];\n  const labelRef = useRef<HTMLSpanElement>(null);\n  const doneRef = useRef<HTMLSpanElement>(null);\n  const [text, setText] = useState(0);\n\n  // Hidden copies of both words share the label's grid cell, so the slot is always as wide as the longer one. The confirmation\n  // swap then never changes the width, and the server render already has the right width before anything is measured.\n  // Changes under half a pixel are ignored, so a re-measure never makes the row wobble.\n  useLayoutEffect(() => {\n    const measure = () => {\n      const next = Math.max(\n        labelRef.current?.getBoundingClientRect().width ?? 0,\n        doneRef.current?.getBoundingClientRect().width ?? 0,\n      );\n      setText((current) => (Math.abs(current - next) < 0.5 ? current : next));\n    };\n    measure();\n    if (typeof ResizeObserver === \"undefined\") return;\n    const observer = new ResizeObserver(measure);\n    if (labelRef.current) observer.observe(labelRef.current);\n    if (doneRef.current) observer.observe(doneRef.current);\n    return () => observer.disconnect();\n  }, [item.label, item.doneLabel, size]);\n\n  const showDone = done && !!item.doneLabel;\n  const measured = text > 0;\n  const target = expanded && measured ? m.start + m.icon + m.gap + text + m.end : m.height;\n  const width = useMotionValue<number>(target);\n  const settled = useRef(false);\n\n  // Width rides the morph spring, so neighbours slide and the group's own width follows without a jump. The first measured\n  // width (and every width under reduced motion) is placed at once.\n  useLayoutEffect(() => {\n    if (!settled.current || reduced) {\n      width.jump(target);\n      if (measured) settled.current = true;\n      return;\n    }\n    animate(width, target, motionTokens.spring.morph);\n  }, [target, measured, reduced, width]);\n\n  const word = showDone ? item.doneLabel! : item.label;\n  return (\n    <motion.button\n      type=\"button\"\n      className={cn(size === \"sm\" ? \"text-xs\" : \"text-sm\", itemClass)}\n      style={{ width }}\n      data-ebg-item=\"\"\n      data-id={item.id}\n      data-tone={item.tone ?? \"neutral\"}\n      data-state={showDone ? \"done\" : \"idle\"}\n      data-expanded={expanded || undefined}\n      data-unmeasured={measured ? undefined : \"\"}\n      aria-label={item.label}\n      aria-disabled={item.disabled || undefined}\n      aria-busy={busy || undefined}\n      tabIndex={tabStop ? 0 : -1}\n      onPointerDown={(event) => onPointerDown(item, event)}\n      onClick={() => onClick(item)}\n      onFocus={(event) => onFocus(item, event)}\n    >\n      <span className={iconClass} aria-hidden=\"true\">\n        <AnimatePresence initial={false}>\n          <motion.span\n            key={showDone ? \"done\" : \"icon\"}\n            className=\"grid size-full place-items-center [grid-area:1/1] [&_svg]:size-full [&_svg]:[stroke-width:1.75]\"\n            initial={reduced ? fadeIn : iconIn}\n            animate={rest}\n            exit={reduced ? fadeOut : iconOut}\n            transition={reduced ? instant : iconEnter}\n          >\n            {showDone ? <DrawnCheck reduced={reduced} /> : item.icon}\n          </motion.span>\n        </AnimatePresence>\n      </span>\n      <motion.span\n        className=\"inline-grid flex-none items-center justify-items-start\"\n        aria-hidden=\"true\"\n        variants={reduced ? reducedLabelVariants : labelVariants}\n        initial={false}\n        animate={expanded ? \"shown\" : \"hidden\"}\n      >\n        <AnimatePresence initial={false}>\n          <motion.span\n            key={word}\n            className=\"[grid-area:1/1] whitespace-nowrap\"\n            initial={reduced ? fadeIn : textIn}\n            animate={rest}\n            exit={reduced ? fadeOut : textOut}\n            transition={reduced ? instant : textEnter}\n          >\n            {word}\n          </motion.span>\n        </AnimatePresence>\n        <span ref={labelRef} className=\"pointer-events-none invisible [grid-area:1/1] whitespace-nowrap\">\n          {item.label}\n        </span>\n        {item.doneLabel ? (\n          <span ref={doneRef} className=\"pointer-events-none invisible [grid-area:1/1] whitespace-nowrap\">\n            {item.doneLabel}\n          </span>\n        ) : null}\n      </motion.span>\n    </motion.button>\n  );\n}\n\n/**\n * A compact pill of icon buttons for actions. One action always shows its label; the one you point at or focus takes over the\n * label while its neighbours slide aside, and an action can confirm in place: the icon draws a check and the label becomes a past tense word.\n * On touch, the first tap reveals the label and a second tap runs the action.\n */\nexport function ExpandingButtonGroup({\n  items,\n  label,\n  size = \"md\",\n  defaultExpanded = null,\n  onAction,\n  confirmDuration = 1400,\n  className,\n  style,\n}: ExpandingButtonGroupProps) {\n  const reduced = useReducedMotion() ?? false;\n  const root = useRef<HTMLDivElement>(null);\n  const [intent, setIntent] = useState<string | null>(null);\n  const [done, setDone] = useState<{ id: string; seq: number } | null>(null);\n  const [busy, setBusy] = useState<string | null>(null);\n  const [announcement, setAnnouncement] = useState(\"\");\n  const [focused, setFocused] = useState<string | null>(null);\n\n  // Refs mirror what the pointer and keyboard are doing right now, so leave and blur handlers can fall back correctly.\n  const pointerOver = useRef<string | null>(null);\n  const pointerAt = useRef<{ x: number; y: number } | null>(null);\n  const keyFocus = useRef<string | null>(null);\n  const touchOpen = useRef<string | null>(null);\n  const press = useRef<{ id: string; kind: string; wasExpanded: boolean } | null>(null);\n  const doneRef = useRef<string | null>(null);\n  const timer = useRef<ReturnType<typeof setTimeout> | null>(null);\n  const mounted = useRef(true);\n  const seq = useRef(0);\n\n  useEffect(() => {\n    mounted.current = true;\n    return () => {\n      mounted.current = false;\n      if (timer.current) clearTimeout(timer.current);\n    };\n  }, []);\n\n  const has = (id: string | null) => !!id && items.some((item) => item.id === id);\n  // One action is always expanded: the resting one is defaultExpanded, else the first enabled action.\n  const firstEnabled = items.find((item) => !item.disabled)?.id ?? items[0]?.id ?? null;\n  const restId = has(defaultExpanded) ? defaultExpanded : firstEnabled;\n  const expandedId = has(intent) ? intent : restId;\n  const tabStop = has(focused) ? focused : restId;\n\n  // A tap outside closes a label opened by a first tap.\n  useEffect(() => {\n    if (!intent || touchOpen.current !== intent) return;\n    const onDown = (event: globalThis.PointerEvent) => {\n      if (root.current?.contains(event.target as Node)) return;\n      touchOpen.current = null;\n      setIntent(doneRef.current ?? keyFocus.current);\n    };\n    document.addEventListener(\"pointerdown\", onDown, true);\n    return () => document.removeEventListener(\"pointerdown\", onDown, true);\n  }, [intent]);\n\n  const confirm = useCallback(\n    (item: ExpandingButtonGroupItem) => {\n      if (timer.current) clearTimeout(timer.current);\n      seq.current += 1;\n      doneRef.current = item.id;\n      setDone({ id: item.id, seq: seq.current });\n      // A zero width space alternates so the same word is announced again on a repeat action.\n      setAnnouncement(`${item.doneLabel}${seq.current % 2 ? \"\" : \"​\"}`);\n      timer.current = setTimeout(() => {\n        doneRef.current = null;\n        setDone(null);\n        // Collapse unless the pointer or the keyboard is still on it.\n        if (pointerOver.current !== item.id && keyFocus.current !== item.id) {\n          if (touchOpen.current === item.id) touchOpen.current = null;\n          setIntent((current) => (current === item.id ? (pointerOver.current ?? keyFocus.current) : current));\n        }\n      }, confirmDuration);\n    },\n    [confirmDuration],\n  );\n\n  const activate = useCallback(\n    async (item: ExpandingButtonGroupItem) => {\n      if (item.disabled || busy === item.id || doneRef.current === item.id) return;\n      setIntent(item.id);\n      onAction?.(item.id);\n      let result: void | boolean;\n      try {\n        const pending = item.onSelect?.();\n        if (pending instanceof Promise) {\n          setBusy(item.id);\n          result = await pending;\n        } else result = pending;\n      } catch {\n        result = false;\n      } finally {\n        if (mounted.current) setBusy((current) => (current === item.id ? null : current));\n      }\n      if (!mounted.current || result === false || !item.doneLabel) return;\n      confirm(item);\n    },\n    [busy, confirm, onAction],\n  );\n\n  const onItemPointerDown = (item: ExpandingButtonGroupItem, event: PointerEvent<HTMLButtonElement>) => {\n    press.current = { id: item.id, kind: event.pointerType, wasExpanded: expandedId === item.id };\n  };\n\n  const onItemClick = (item: ExpandingButtonGroupItem) => {\n    const last = press.current;\n    press.current = null;\n    // Touch has no hover: the first tap opens the label, the second runs it. An item already showing its label runs at once.\n    if (last && last.id === item.id && last.kind === \"touch\" && !last.wasExpanded) {\n      touchOpen.current = item.id;\n      setIntent(item.id);\n      return;\n    }\n    if (last?.kind === \"touch\") touchOpen.current = item.id;\n    void activate(item);\n  };\n\n  const onItemFocus = (item: ExpandingButtonGroupItem, event: FocusEvent<HTMLButtonElement>) => {\n    setFocused(item.id);\n    // Only keyboard focus expands; a mouse click or a tap already has its own path, and would otherwise leave the label open.\n    let visible = true;\n    try {\n      visible = event.currentTarget.matches(\":focus-visible\");\n    } catch {\n      /* older engines expand on any focus */\n    }\n    if (!visible) return;\n    keyFocus.current = item.id;\n    setIntent(item.id);\n  };\n\n  const onBlur = (event: FocusEvent<HTMLDivElement>) => {\n    if (root.current?.contains(event.relatedTarget as Node | null)) return;\n    keyFocus.current = null;\n    touchOpen.current = null;\n    setIntent(pointerOver.current ?? doneRef.current);\n  };\n\n  // Hover follows real pointer movement only. Expanding moves the buttons, and a button that slides under a resting pointer\n  // must not take over, or two neighbours could trade places back and forth.\n  const onPointerMove = (event: PointerEvent<HTMLDivElement>) => {\n    if (event.pointerType !== \"mouse\") return;\n    const at = pointerAt.current;\n    if (at && Math.abs(at.x - event.clientX) < 1 && Math.abs(at.y - event.clientY) < 1) return;\n    pointerAt.current = { x: event.clientX, y: event.clientY };\n    const button = (event.target as HTMLElement).closest<HTMLElement>(\"[data-ebg-item]\");\n    const id = button?.dataset.id;\n    if (!button || !id || id === pointerOver.current) return;\n    // Hysteresis: moving from one action to the next needs the pointer a few pixels inside the new one, so resting on a\n    // boundary never flips between them.\n    if (pointerOver.current) {\n      const box = button.getBoundingClientRect();\n      if (event.clientX < box.left + hysteresis || event.clientX > box.right - hysteresis) return;\n    }\n    pointerOver.current = id;\n    setIntent(id);\n  };\n\n  const onPointerLeave = (event: PointerEvent<HTMLDivElement>) => {\n    if (event.pointerType !== \"mouse\") return;\n    pointerAt.current = null;\n    pointerOver.current = null;\n    setIntent(doneRef.current ?? keyFocus.current);\n  };\n\n  const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {\n    const list = Array.from(root.current?.querySelectorAll<HTMLButtonElement>(\"[data-ebg-item]\") ?? []);\n    const index = list.findIndex((button) => button === document.activeElement);\n    if (index < 0) return;\n    const last = list.length - 1;\n    const target =\n      event.key === \"ArrowRight\"\n        ? index === last\n          ? 0\n          : index + 1\n        : event.key === \"ArrowLeft\"\n          ? index === 0\n            ? last\n            : index - 1\n          : event.key === \"Home\"\n            ? 0\n            : event.key === \"End\"\n              ? last\n              : -1;\n    if (target < 0) return;\n    event.preventDefault();\n    list[target]!.focus();\n  };\n\n  const m = metrics[size];\n  const vars = {\n    \"--ebg-h\": `${m.height}px`,\n    \"--ebg-icon\": `${m.icon}px`,\n    \"--ebg-start\": `${m.start}px`,\n    \"--ebg-gap\": `${m.gap}px`,\n    \"--ebg-end\": `${m.end}px`,\n    ...style,\n  } as CSSProperties;\n\n  return (\n    // oxlint-disable-next-line jsx-a11y/interactive-supports-focus -- toolbar pattern: focus moves between the buttons with a roving tabindex, so the container itself is not a tab stop\n    <div\n      ref={root}\n      role=\"toolbar\"\n      aria-label={label}\n      aria-orientation=\"horizontal\"\n      className={cn(groupClass, className)}\n      style={vars}\n      onKeyDown={onKeyDown}\n      onBlur={onBlur}\n      onPointerMove={onPointerMove}\n      onPointerLeave={onPointerLeave}\n    >\n      {items.map((item) => (\n        <Item\n          key={item.id}\n          item={item}\n          size={size}\n          expanded={expandedId === item.id}\n          done={done?.id === item.id}\n          busy={busy === item.id}\n          tabStop={tabStop === item.id}\n          reduced={reduced}\n          onPointerDown={onItemPointerDown}\n          onClick={onItemClick}\n          onFocus={onItemFocus}\n        />\n      ))}\n      <output\n        className=\"absolute size-px overflow-hidden whitespace-nowrap [clip:rect(0_0_0_0)] [clip-path:inset(50%)]\"\n        aria-live=\"polite\"\n      >\n        {announcement}\n      </output>\n    </div>\n  );\n}\n\nexport default ExpandingButtonGroup;\n",
      "type": "registry:component",
      "target": "@components/iris/expanding-button-group/expanding-button-group.tsx"
    }
  ],
  "docs": "Docs and live preview: https://irisui.dev/components/expanding-button-group",
  "meta": {
    "kind": "component",
    "group": "Actions",
    "export": "ExpandingButtonGroup",
    "tags": [
      "action",
      "group",
      "toolbar",
      "new"
    ],
    "docs": "https://irisui.dev/components/expanding-button-group"
  }
}
