{"$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://uiarc.dev/r/arc-motion-tokens.json"],"files":[{"path":"registry/components/expanding-button-group/expanding-button-group.tsx","type":"registry:component","target":"@components/arc/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 styles from \"./expanding-button-group.module.css\";\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. */\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: { opacity: 1, x: 0, filter: \"blur(0px)\", transition: { duration: motionTokens.duration.standard, ease: enter, delay: .04 } },\n  hidden: { opacity: 0, x: -6, filter: `blur(${motionTokens.blur.subtle}px)`, transition: { duration: motionTokens.duration.instant, ease: standard } },\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 = { opacity: 0, y: \"-0.4em\", filter: `blur(${motionTokens.blur.subtle}px)`, transition: { duration: motionTokens.duration.fast, ease: standard } };\nconst iconIn: TargetAndTransition = { opacity: 0, scale: .55, filter: `blur(${motionTokens.blur.subtle}px)` };\nconst iconOut: TargetAndTransition = { ...iconIn, transition: { duration: motionTokens.duration.fast, ease: standard } };\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 = { ...motionTokens.spring.snappy, opacity: { duration: motionTokens.duration.fast, ease: enter }, filter: { duration: motionTokens.duration.fast, ease: enter } };\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 <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth={2} strokeLinecap=\"round\" strokeLinejoin=\"round\">\n    <motion.path d=\"M4 12.5l5 5L20 6.5\" initial={reduced ? false : { pathLength: 0, opacity: 0 }} animate={{ pathLength: 1, opacity: 1 }} transition={{ pathLength: { duration: motionTokens.duration.standard, ease: enter, delay: .06 }, opacity: { duration: .05, delay: .06 } }} />\n  </svg>;\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(labelRef.current?.getBoundingClientRect().width ?? 0, doneRef.current?.getBoundingClientRect().width ?? 0);\n      setText(current => Math.abs(current - next) < .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 <motion.button\n    type=\"button\"\n    className={styles.item}\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={styles.icon} aria-hidden=\"true\">\n      <AnimatePresence initial={false}>\n        <motion.span key={showDone ? \"done\" : \"icon\"} className={styles.glyph} initial={reduced ? fadeIn : iconIn} animate={rest} exit={reduced ? fadeOut : iconOut} transition={reduced ? instant : iconEnter}>\n          {showDone ? <DrawnCheck reduced={reduced} /> : item.icon}\n        </motion.span>\n      </AnimatePresence>\n    </span>\n    <motion.span className={styles.label} aria-hidden=\"true\" variants={reduced ? reducedLabelVariants : labelVariants} initial={false} animate={expanded ? \"shown\" : \"hidden\"}>\n      <AnimatePresence initial={false}>\n        <motion.span key={word} className={styles.word} initial={reduced ? fadeIn : textIn} animate={rest} exit={reduced ? fadeOut : textOut} transition={reduced ? instant : textEnter}>{word}</motion.span>\n      </AnimatePresence>\n      <span ref={labelRef} className={styles.sizer}>{item.label}</span>\n      {item.doneLabel ? <span ref={doneRef} className={styles.sizer}>{item.doneLabel}</span> : null}\n    </motion.span>\n  </motion.button>;\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({ items, label, size = \"md\", defaultExpanded = null, onAction, confirmDuration = 1400, className, style }: 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 () => { mounted.current = false; if (timer.current) clearTimeout(timer.current); };\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((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  }, [confirmDuration]);\n\n  const activate = useCallback(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  }, [busy, confirm, onAction]);\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 { visible = event.currentTarget.matches(\":focus-visible\"); } catch { /* older engines expand on any focus */ }\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 = event.key === \"ArrowRight\" ? (index === last ? 0 : index + 1)\n      : event.key === \"ArrowLeft\" ? (index === 0 ? last : index - 1)\n        : event.key === \"Home\" ? 0 : event.key === \"End\" ? last : -1;\n    if (target < 0) return;\n    event.preventDefault();\n    list[target]!.focus();\n  };\n\n  const m = metrics[size];\n  const vars = { \"--ebg-h\": `${m.height}px`, \"--ebg-icon\": `${m.icon}px`, \"--ebg-start\": `${m.start}px`, \"--ebg-gap\": `${m.gap}px`, \"--ebg-end\": `${m.end}px`, ...style } as CSSProperties;\n\n  return <div\n    ref={root}\n    role=\"toolbar\"\n    aria-label={label}\n    aria-orientation=\"horizontal\"\n    className={[styles.group, styles[size], className].filter(Boolean).join(\" \")}\n    style={vars}\n    onKeyDown={onKeyDown}\n    onBlur={onBlur}\n    onPointerMove={onPointerMove}\n    onPointerLeave={onPointerLeave}\n  >\n    {items.map(item => <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    <span className={styles.srOnly} role=\"status\" aria-live=\"polite\">{announcement}</span>\n  </div>;\n}\n\nexport default ExpandingButtonGroup;\n"},{"path":"registry/components/expanding-button-group/expanding-button-group.module.css","type":"registry:component","target":"@components/arc/expanding-button-group/expanding-button-group.module.css","content":"/* A flat pill on the surface. Geometry comes from the component as --ebg-* variables, so the measured widths and the CSS agree. */\n.group { --ebg-h: 36px; --ebg-icon: 16px; --ebg-start: 10px; --ebg-gap: 7px; --ebg-end: 14px; --ebg-pad: 3px; position: relative; isolation: isolate; display: inline-flex; max-width: 100%; flex: none; align-items: center; gap: 2px; padding: var(--ebg-pad); border: 1px solid var(--border); border-radius: var(--radius-pill); background: var(--surface); }\n\n/* The box never scales: its width is the animation, so a press answers with the icon and with color. 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. */\n.item { position: relative; display: inline-flex; height: var(--ebg-h); min-width: var(--ebg-h); flex: none; align-items: center; justify-content: flex-start; gap: var(--ebg-gap); overflow: clip; border: 0; border-radius: var(--radius-pill); padding: 0 0 0 var(--ebg-start); background: transparent; color: var(--text-secondary); font: inherit; font-size: var(--text-sm); font-weight: 500; line-height: 1; letter-spacing: var(--tracking-body); white-space: nowrap; cursor: pointer; -webkit-tap-highlight-color: transparent; touch-action: manipulation; transition: color var(--duration-fast) var(--ease-standard), background-color var(--duration-standard) var(--ease-standard); }\n.sm .item { font-size: var(--text-xs); }\n.item[data-expanded] { color: var(--foreground); background: var(--surface-muted); }\n.item[data-tone=\"danger\"][data-expanded] { color: var(--danger); background: color-mix(in oklab, var(--danger) 10%, var(--surface)); }\n/* The confirmation carries its meaning on the check, with a faint tint behind the whole button. */\n.item[data-state=\"done\"]:not([data-tone=\"danger\"]) { background: color-mix(in oklab, var(--success) 10%, var(--surface)); }\n.item[data-state=\"done\"]:not([data-tone=\"danger\"]) .icon { color: var(--success); }\n.item[aria-busy=\"true\"] { cursor: progress; }\n.item[aria-busy=\"true\"] .icon { animation: busy 1.1s var(--ease-in-out) infinite; }\n.item[aria-disabled=\"true\"] { cursor: not-allowed; color: var(--text-muted); opacity: .5; }\n.item[aria-disabled=\"true\"][data-expanded] { background: var(--surface-muted); color: var(--text-secondary); }\n:global(:root[data-theme=\"dark\"]) .item[data-tone=\"danger\"][data-expanded] { background: color-mix(in oklab, var(--danger) 14%, var(--surface)); }\n:global(:root[data-theme=\"dark\"]) .item[data-state=\"done\"]:not([data-tone=\"danger\"]) { background: color-mix(in oklab, var(--success) 14%, var(--surface)); }\n\n.icon { position: relative; display: grid; width: var(--ebg-icon); height: var(--ebg-icon); flex: none; place-items: center; transition: transform 320ms var(--ease-spring); }\n.item:active:not([aria-disabled=\"true\"]) .icon { transform: scale(.86); transition-duration: 90ms; transition-timing-function: var(--ease-standard); }\n.glyph { display: grid; grid-area: 1 / 1; place-items: center; width: 100%; height: 100%; }\n.glyph svg { width: 100%; height: 100%; stroke-width: 1.75; }\n\n/* Outgoing and incoming words share one cell, so a confirmation crossfades in place while the width springs between them. */\n.label { display: inline-grid; flex: none; align-items: center; justify-items: start; }\n.word { grid-area: 1 / 1; white-space: nowrap; }\n\n/* Invisible copies of both words hold the slot at the longer one; they are also what gets measured for the width target. */\n.sizer { grid-area: 1 / 1; visibility: hidden; pointer-events: none; white-space: nowrap; }\n\n/* Before the first measurement (the server render), the expanded action takes its natural width, which is exactly the width the\n   spring later targets, so hydration never moves anything. */\n.item[data-unmeasured][data-expanded] { width: auto !important; padding-inline-end: var(--ebg-end); }\n\n.srOnly { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap; }\n\n@keyframes busy { 50% { opacity: .45; } }\n\n@media (prefers-reduced-motion: reduce) {\n  .item { transition: color var(--duration-instant) linear, background-color var(--duration-instant) linear; }\n  .icon, .item:active:not([aria-disabled=\"true\"]) .icon { transform: none; transition: none; }\n  .item[aria-busy=\"true\"] .icon { animation: none; opacity: .6; }\n}\n"}],"docs":"Docs and live preview: https://uiarc.dev/components/expanding-button-group","meta":{"tier":"free","kind":"component","tags":["action","group","toolbar","new"],"docs":"https://uiarc.dev/components/expanding-button-group","markdown":"https://uiarc.dev/components/expanding-button-group/markdown"}}