{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"floating-button-group","type":"registry:ui","title":"Floating button group","description":"Separate soft buttons in a quiet tray, with one shared highlight that morphs from button to button as you move, and a pressed state that settles in place.","categories":["actions"],"dependencies":["@radix-ui/react-tooltip","motion"],"registryDependencies":["https://ui.teamofsilicons.com/r/silicon-motion-tokens.json","https://ui.teamofsilicons.com/r/tooltip.json"],"files":[{"path":"registry/components/floating-button-group/floating-button-group.tsx","type":"registry:component","target":"@components/silicon-ui/floating-button-group/floating-button-group.tsx","content":"\"use client\";\n\nimport { Fragment, useCallback, useEffect, useLayoutEffect, useRef, useState } from \"react\";\nimport type { CSSProperties, FocusEvent, KeyboardEvent, ReactElement, ReactNode } from \"react\";\nimport * as TooltipPrimitive from \"@radix-ui/react-tooltip\";\nimport { AnimatePresence, animate, motion, useIsPresent, useMotionValue, useReducedMotion } from \"motion/react\";\nimport type { TargetAndTransition } from \"motion/react\";\nimport { motionTokens } from \"../lib/motion-tokens\";\nimport { Tooltip } from \"../tooltip/tooltip\";\nimport styles from \"./floating-button-group.module.css\";\n\nexport type FloatingButtonGroupVariant = \"muted\" | \"floating\";\nexport type FloatingButtonGroupSize = \"sm\" | \"md\";\nexport type FloatingButtonGroupOrientation = \"horizontal\" | \"vertical\";\n\nexport interface FloatingButtonGroupAction {\n  type?: \"action\";\n  /** Stable key, also passed to onAction. */\n  id: string;\n  /** Visible label, and the accessible name when the item shows only its icon. */\n  label: string;\n  icon?: ReactNode;\n  /** Shows only the icon. The label moves into a tooltip and the accessible name. Overrides the group's iconOnly. */\n  iconOnly?: boolean;\n  onSelect?: () => void;\n  disabled?: boolean;\n  /** Marks an action that switches a mode on, such as Present. Sets aria-pressed and keeps a quiet tint while on. */\n  pressed?: boolean;\n  /** A shortcut hint shown in the tooltip, such as \"⌘Z\". The component does not bind the key. */\n  shortcut?: string;\n  /** Other labels this item can switch to, such as [\"Copied\"] for Share. The button keeps the width of the widest, so a label change never moves anything. */\n  reserveLabels?: readonly string[];\n}\n\nexport interface FloatingButtonGroupSeparator {\n  type: \"separator\";\n  id?: string;\n}\n\nexport type FloatingButtonGroupItem = FloatingButtonGroupAction | FloatingButtonGroupSeparator;\n\nexport interface FloatingButtonGroupProps {\n  items: readonly FloatingButtonGroupItem[];\n  /** Accessible name of the toolbar, such as \"Board actions\". */\n  label: string;\n  /** muted sits quietly on a page; floating raises the tray with the floating shadow for overlays such as a selection toolbar. */\n  variant?: FloatingButtonGroupVariant;\n  size?: FloatingButtonGroupSize;\n  orientation?: FloatingButtonGroupOrientation;\n  /** Shows every item as an icon only, with its label in a tooltip. Items with their own iconOnly win. */\n  iconOnly?: boolean;\n  /** Side of the item tooltips. Defaults to top for a row and right for a column. */\n  tooltipSide?: \"top\" | \"bottom\" | \"left\" | \"right\";\n  /** Called with the item id after the item's own onSelect. */\n  onAction?: (id: string) => void;\n  className?: string;\n  style?: CSSProperties;\n}\n\nconst isAction = (item: FloatingButtonGroupItem): item is FloatingButtonGroupAction => item.type !== \"separator\";\nconst standard = [...motionTokens.ease.standard] as [number, number, number, number];\nconst enter = [...motionTokens.ease.enter] as [number, number, number, number];\n\nconst rest: TargetAndTransition = { opacity: 1, y: 0, filter: \"blur(0px)\" };\nconst textIn: TargetAndTransition = { opacity: 0, y: 4, filter: `blur(${motionTokens.blur.soft}px)` };\nconst textOut: TargetAndTransition = { opacity: 0, y: -3, filter: `blur(${motionTokens.blur.soft}px)`, transition: { duration: motionTokens.duration.fast, ease: standard } };\nconst fadeIn: TargetAndTransition = { opacity: 0 };\nconst fadeOut: TargetAndTransition = { opacity: 0, transition: { duration: motionTokens.duration.instant } };\n\nfunction Phase({ children, reduced }: { children: ReactNode; reduced: boolean }) {\n  const present = useIsPresent();\n  return <motion.span className={styles.phase} aria-hidden={present ? undefined : true} initial={reduced ? fadeIn : textIn} animate={rest} exit={reduced ? fadeOut : textOut} transition={{ duration: reduced ? motionTokens.duration.instant : motionTokens.duration.standard, ease: enter }}>{children}</motion.span>;\n}\n\n/**\n * Icon and label. Every label the item can show is laid out invisibly in the same grid cell, so the button always has the width\n * of the widest one: a label change crossfades in place and never resizes the button, its neighbours or the tray.\n */\nfunction ItemBody({ label, icon, iconOnly, reserve, reduced }: { label: string; icon?: ReactNode; iconOnly: boolean; reserve: readonly string[]; reduced: boolean }) {\n  const ghosts = iconOnly ? [] : Array.from(new Set([label, ...reserve]));\n  return <span className={styles.slot}>\n    {ghosts.map(text => <span key={text} className={styles.ghost} aria-hidden=\"true\">\n      {icon ? <span className={styles.iconSpace} /> : null}\n      <span className={styles.text}>{text}</span>\n    </span>)}\n    <AnimatePresence initial={false}>\n      <Phase key={label} reduced={reduced}>\n        {icon ? <span className={styles.icon} aria-hidden=\"true\">{icon}</span> : null}\n        {iconOnly ? null : <span className={styles.text}>{label}</span>}\n      </Phase>\n    </AnimatePresence>\n  </span>;\n}\n\nfunction TipContent({ label, shortcut }: { label: string; shortcut?: string }) {\n  return <span className={styles.tip}>{label}{shortcut ? <kbd className={styles.kbd}>{shortcut}</kbd> : null}</span>;\n}\n\n/** Side tooltips for a column. The library Tooltip opens above or below, which would cover the neighbouring button. */\nfunction SideTip({ side, content, children }: { side: \"left\" | \"right\"; content: ReactNode; children: ReactElement }) {\n  return <TooltipPrimitive.Root>\n    <TooltipPrimitive.Trigger asChild>{children}</TooltipPrimitive.Trigger>\n    <TooltipPrimitive.Portal>\n      <TooltipPrimitive.Content className={styles.sideTip} side={side} sideOffset={10} collisionPadding={12}>{content}</TooltipPrimitive.Content>\n    </TooltipPrimitive.Portal>\n  </TooltipPrimitive.Root>;\n}\n\n/**\n * Separate soft buttons in a quiet tray. One shared highlight morphs, in position and size, to whichever button the pointer\n * or keyboard focus is on, fades when the pointer leaves the tray, and settles darker in place while a button is pressed.\n * It is the toolbar's only hover and focus indicator. Arrow keys move focus, Home and End jump to the ends, Enter and Space act.\n */\nexport function FloatingButtonGroup({ items, label, variant = \"muted\", size = \"md\", orientation = \"horizontal\", iconOnly: groupIconOnly = false, tooltipSide, onAction, className, style }: FloatingButtonGroupProps) {\n  const reduced = useReducedMotion() ?? false;\n  const tray = useRef<HTMLDivElement>(null);\n  const nodes = useRef(new Map<string, HTMLButtonElement>());\n  const [hoverId, setHoverId] = useState<string | null>(null);\n  const [focusId, setFocusId] = useState<string | null>(null);\n  const [pressing, setPressing] = useState(false);\n  const [tabStop, setTabStop] = useState<string | null>(null);\n\n  const actions = items.filter(isAction);\n  const byId = new Map(actions.map(action => [action.id, action]));\n  const enabled = (id: string | null) => !!id && !!byId.get(id) && !byId.get(id)?.disabled;\n  // Pointer wins over keyboard focus while it is in the tray; a disabled item under the pointer hands back to focus.\n  const activeId = enabled(hoverId) ? hoverId : focusId && byId.has(focusId) ? focusId : null;\n  const stop = enabled(tabStop) ? tabStop : actions.find(action => !action.disabled)?.id ?? null;\n  const vertical = orientation === \"vertical\";\n  const side = tooltipSide ?? (vertical ? \"right\" : \"top\");\n  const idsKey = actions.map(action => action.id).join(\"\\u0000\");\n\n  // The highlight lives in motion values so moving it never re-renders the buttons.\n  const x = useMotionValue(0), y = useMotionValue(0), width = useMotionValue(0), height = useMotionValue(0);\n  const opacity = useMotionValue(0), scale = useMotionValue(1);\n  const traveling = useRef(false);\n  const activeRef = useRef(activeId);\n  const pressingRef = useRef(pressing);\n\n  /** The button's box in tray coordinates, exact to the device pixel (offsetLeft rounds to whole pixels) and free of ancestor transforms. */\n  const measure = useCallback((node: HTMLElement) => {\n    const root = tray.current;\n    if (!root) return null;\n    const outer = root.getBoundingClientRect(), box = node.getBoundingClientRect();\n    const unscale = outer.width ? root.offsetWidth / outer.width : 1;\n    const dpr = window.devicePixelRatio || 1;\n    const snap = (value: number) => Math.round(value * unscale * dpr) / dpr;\n    return { x: snap(box.left - outer.left) - root.clientLeft, y: snap(box.top - outer.top) - root.clientTop, width: snap(box.width), height: snap(box.height) };\n  }, []);\n\n  const place = useCallback((id: string | null, mode: \"move\" | \"follow\") => {\n    const node = id ? nodes.current.get(id) : undefined;\n    if (!node) {\n      animate(opacity, 0, { duration: reduced ? motionTokens.duration.instant : motionTokens.duration.standard, ease: standard });\n      return;\n    }\n    const rect = measure(node);\n    if (!rect) return;\n    const visible = opacity.get() > .15;\n    // Travel between buttons on the morph spring. Appearing from nothing, and every move with reduced motion, lands in place.\n    const travel = !reduced && visible && (mode === \"move\" || traveling.current);\n    if (travel) {\n      traveling.current = true;\n      animate(x, rect.x, motionTokens.spring.morph);\n      animate(y, rect.y, motionTokens.spring.morph);\n      animate(width, rect.width, motionTokens.spring.morph);\n      animate(height, rect.height, { ...motionTokens.spring.morph, onComplete: () => { traveling.current = false; } });\n    } else {\n      traveling.current = false;\n      x.jump(rect.x); y.jump(rect.y); width.jump(rect.width); height.jump(rect.height);\n      if (!visible && !reduced) {\n        scale.jump(.94);\n        animate(scale, pressingRef.current ? .965 : 1, motionTokens.spring.snappy);\n      }\n    }\n    if (opacity.get() !== 1) animate(opacity, 1, { duration: reduced ? motionTokens.duration.instant : motionTokens.duration.fast, ease: standard });\n  }, [height, measure, opacity, reduced, scale, width, x, y]);\n\n  useLayoutEffect(() => {\n    activeRef.current = activeId;\n    place(activeId, \"move\");\n  }, [activeId, place]);\n\n  useEffect(() => {\n    pressingRef.current = pressing;\n    animate(scale, pressing && activeId ? .965 : 1, reduced ? { duration: 0 } : motionTokens.spring.snappy);\n  }, [activeId, pressing, reduced, scale]);\n\n  // A font that loads or a container that reflows moves the buttons; the highlight follows without travel.\n  useEffect(() => {\n    const root = tray.current;\n    if (!root || typeof ResizeObserver === \"undefined\") return;\n    const observer = new ResizeObserver(() => { if (activeRef.current) place(activeRef.current, \"follow\"); });\n    observer.observe(root);\n    nodes.current.forEach(node => observer.observe(node));\n    return () => observer.disconnect();\n  }, [idsKey, place]);\n\n  // A native listener, because React bubbles pointer events through portals: moving from a button onto its tooltip\n  // would not count as leaving the tray, and the highlight would stay behind.\n  useEffect(() => {\n    const root = tray.current;\n    if (!root) return;\n    const leave = () => { setHoverId(null); setPressing(false); };\n    root.addEventListener(\"pointerleave\", leave);\n    return () => root.removeEventListener(\"pointerleave\", leave);\n  }, []);\n\n  const buttons = () => Array.from(tray.current?.querySelectorAll<HTMLButtonElement>(\"[data-fbg-item]\") ?? []);\n\n  const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {\n    const all = buttons();\n    const index = all.findIndex(node => node === document.activeElement);\n    if (index < 0) return;\n    const next = vertical ? \"ArrowDown\" : \"ArrowRight\";\n    const previous = vertical ? \"ArrowUp\" : \"ArrowLeft\";\n    const usable = (node: HTMLButtonElement) => node.getAttribute(\"aria-disabled\") !== \"true\";\n    let target: HTMLButtonElement | undefined;\n    if (event.key === \"Home\") target = all.find(usable);\n    else if (event.key === \"End\") target = [...all].reverse().find(usable);\n    else if (event.key === next || event.key === previous) {\n      const step = event.key === next ? 1 : -1;\n      for (let offset = 1; offset <= all.length; offset += 1) {\n        const candidate = all[(index + step * offset + all.length * offset) % all.length]!;\n        if (usable(candidate)) { target = candidate; break; }\n      }\n    } else return;\n    event.preventDefault();\n    if (!target) return;\n    // The keyboard takes the highlight back until the pointer moves again.\n    setHoverId(null);\n    target.focus();\n  };\n\n  const onBlur = (event: FocusEvent<HTMLDivElement>) => {\n    if (tray.current?.contains(event.relatedTarget as Node | null)) return;\n    setFocusId(null);\n    setPressing(false);\n  };\n\n  const classes = [styles.group, styles[variant], styles[size], vertical ? styles.vertical : \"\", className].filter(Boolean).join(\" \");\n\n  const group = <div\n    ref={tray}\n    className={classes}\n    style={style}\n    role=\"toolbar\"\n    aria-label={label}\n    aria-orientation={orientation}\n    data-variant={variant}\n    onKeyDown={onKeyDown}\n    onBlur={onBlur}\n  >\n    <motion.span className={styles.highlight} data-pressing={pressing && activeId ? \"\" : undefined} style={{ x, y, width, height, opacity, scale }} aria-hidden=\"true\" />\n    {items.map((item, position) => {\n      if (!isAction(item)) return <span key={item.id ?? `separator-${position}`} className={styles.separator} role=\"separator\" aria-orientation={vertical ? \"horizontal\" : \"vertical\"} />;\n      const { id, label: itemLabel, icon, onSelect, disabled = false, pressed, shortcut, reserveLabels = [] } = item;\n      const iconOnly = !!icon && (item.iconOnly ?? groupIconOnly);\n      const button = <button\n        ref={node => { if (node) nodes.current.set(id, node); else nodes.current.delete(id); }}\n        type=\"button\"\n        className={styles.item}\n        data-fbg-item=\"\"\n        data-active={activeId === id ? \"\" : undefined}\n        data-icon={icon ? \"\" : undefined}\n        data-icon-only={iconOnly ? \"\" : undefined}\n        aria-label={itemLabel}\n        aria-pressed={pressed === undefined ? undefined : pressed}\n        aria-disabled={disabled || undefined}\n        tabIndex={stop === id ? 0 : -1}\n        onPointerMove={event => { if (event.pointerType === \"mouse\" || event.pointerType === \"pen\") setHoverId(id); }}\n        onPointerDown={event => { if (disabled || event.button !== 0) return; setHoverId(id); setPressing(true); }}\n        onPointerUp={() => setPressing(false)}\n        onPointerCancel={() => setPressing(false)}\n        onPointerLeave={() => setPressing(false)}\n        onKeyDown={event => { if ((event.key === \"Enter\" || event.key === \" \") && !event.repeat && !disabled) setPressing(true); }}\n        onKeyUp={event => { if (event.key === \"Enter\" || event.key === \" \") setPressing(false); }}\n        onFocus={event => {\n          setTabStop(id);\n          let keyboard = false;\n          try { keyboard = event.currentTarget.matches(\":focus-visible\"); } catch { keyboard = false; }\n          setFocusId(keyboard ? id : null);\n        }}\n        onClick={() => {\n          if (disabled) return;\n          onSelect?.();\n          onAction?.(id);\n        }}\n      >\n        <ItemBody label={itemLabel} icon={icon} iconOnly={iconOnly} reserve={reserveLabels} reduced={reduced} />\n      </button>;\n      // Tooltips render no wrapper element, so every button stays a direct child of the tray and measures against it.\n      if (!iconOnly && !shortcut) return <Fragment key={id}>{button}</Fragment>;\n      const tip = <TipContent label={itemLabel} shortcut={shortcut} />;\n      return side === \"top\" || side === \"bottom\"\n        ? <Tooltip key={id} side={side} content={tip}>{button}</Tooltip>\n        : <SideTip key={id} side={side} content={tip}>{button}</SideTip>;\n    })}\n  </div>;\n\n  return side === \"left\" || side === \"right\" ? <TooltipPrimitive.Provider delayDuration={250} skipDelayDuration={300} disableHoverableContent>{group}</TooltipPrimitive.Provider> : group;\n}\n\nexport default FloatingButtonGroup;\n"},{"path":"registry/components/floating-button-group/floating-button-group.module.css","type":"registry:component","target":"@components/silicon-ui/floating-button-group/floating-button-group.module.css","content":"/* Separate soft buttons in a quiet tray, with real gaps between them. The tray always keeps its natural size and never wraps or clips its buttons. The buttons stay transparent: one shared highlight\n   moves underneath their content, so there is only ever one moving surface. Corners are concentric with the tray. */\n.group {\n  --fbg-h: 36px; --fbg-pad: 4px; --fbg-gap: 4px; --fbg-pad-x: 12px;\n  --fbg-outer: var(--radius-control);\n  --fbg-radius: calc(var(--fbg-outer) - var(--fbg-pad) - 1px);\n  --fbg-highlight: color-mix(in oklab, var(--foreground) 7%, transparent);\n  --fbg-highlight-press: color-mix(in oklab, var(--foreground) 12%, transparent);\n  --fbg-on: color-mix(in oklab, var(--control-on) 12%, transparent);\n  --fbg-rule: color-mix(in oklab, var(--foreground) 12%, transparent);\n  /* The muted tray is a translucent tint, not --surface-muted, so it still reads when it sits on a muted surface. */\n  --fbg-tray: color-mix(in oklab, var(--foreground) 4.5%, transparent);\n  --fbg-tray-edge: color-mix(in oklab, var(--foreground) 3%, transparent);\n  position: relative; isolation: isolate; display: inline-flex; flex: none; flex-wrap: nowrap; align-items: center; gap: var(--fbg-gap); padding: var(--fbg-pad); border: 1px solid var(--fbg-tray-edge); border-radius: var(--fbg-outer); background: var(--fbg-tray); color: var(--text-secondary); font-family: var(--font-body); letter-spacing: var(--tracking-body); -webkit-tap-highlight-color: transparent;\n}\n.sm { --fbg-h: 30px; --fbg-pad: 3px; --fbg-gap: 3px; --fbg-pad-x: 10px; --fbg-outer: calc(var(--radius-control) - 3px); }\n.md { --fbg-h: 36px; }\n/* Raised for overlays such as a selection toolbar: the floating layer shadow and a hairline edge. */\n.floating { border-color: var(--border); background: var(--surface-raised); box-shadow: var(--shadow-floating); }\n.vertical { flex-direction: column; align-items: stretch; }\n:global(:root[data-theme=\"dark\"]) .group {\n  --fbg-highlight: color-mix(in oklab, var(--foreground) 9%, transparent);\n  --fbg-highlight-press: color-mix(in oklab, var(--foreground) 15%, transparent);\n  --fbg-on: color-mix(in oklab, var(--control-on) 16%, transparent);\n  --fbg-tray: color-mix(in oklab, var(--foreground) 6%, transparent);\n  --fbg-tray-edge: color-mix(in oklab, var(--foreground) 5%, transparent);\n}\n\n/* The highlight paints above the buttons' own backgrounds (a pressed tint) and below their content. */\n.highlight { position: absolute; z-index: 1; top: 0; left: 0; border-radius: var(--fbg-radius); background: var(--fbg-highlight); pointer-events: none; transition: background-color var(--duration-fast) var(--ease-standard); }\n.highlight[data-pressing] { background: var(--fbg-highlight-press); transition-duration: var(--duration-instant); }\n\n/* Buttons are not positioned, so the highlight measures them against the tray and stacks above their background. */\n.item { display: inline-flex; min-width: var(--fbg-h); height: var(--fbg-h); flex: none; align-items: center; justify-content: center; margin: 0; padding: 0 var(--fbg-pad-x); border: 0; border-radius: var(--fbg-radius); background: transparent; color: inherit; font: inherit; font-size: var(--text-sm); font-weight: 500; line-height: 1; white-space: nowrap; cursor: pointer; user-select: none; -webkit-tap-highlight-color: transparent; touch-action: manipulation; transition: color var(--duration-fast) var(--ease-standard), background-color var(--duration-fast) var(--ease-standard), opacity var(--duration-fast) var(--ease-standard); }\n.sm .item { font-size: var(--text-xs); }\n.item[data-icon]:not([data-icon-only]) { padding-left: calc(var(--fbg-pad-x) - var(--space-hair)); }\n.item[data-icon-only] { width: var(--fbg-h); padding: 0; }\n.item[data-active], .item[aria-pressed=\"true\"] { color: var(--foreground); }\n.item[aria-pressed=\"true\"] { background: var(--fbg-on); }\n.item[aria-disabled=\"true\"] { color: var(--text-muted); cursor: not-allowed; opacity: .5; }\n.vertical .item:not([data-icon-only]) { justify-content: flex-start; }\n.vertical .item[data-icon-only] { align-self: center; }\n\n/* Ghosts of every label share one grid cell with the visible one, so the slot always has the widest label's width. */\n.slot { position: relative; z-index: 2; display: inline-grid; align-items: center; justify-items: center; }\n.slot > * { grid-area: 1 / 1; }\n.vertical .slot { justify-items: start; }\n.ghost { display: inline-flex; align-items: center; gap: var(--space-related); white-space: nowrap; visibility: hidden; pointer-events: none; }\n.iconSpace { display: block; width: 16px; height: 16px; flex: none; }\n.phase { display: inline-flex; align-items: center; gap: var(--space-related); white-space: nowrap; }\n.icon { display: inline-flex; flex: none; }\n.icon svg { width: 16px; height: 16px; stroke-width: 1.75; }\n.text { display: block; }\n\n.separator { flex: none; align-self: center; width: 1px; height: calc(var(--fbg-h) * .5); margin-inline: var(--space-hair); border-radius: var(--radius-full); corner-shape: round; background: var(--fbg-rule); }\n.vertical .separator { width: calc(var(--fbg-h) * .5); height: 1px; margin: var(--space-hair) 0; }\n\n.tip { display: inline-flex; align-items: baseline; gap: var(--space-group); white-space: nowrap; }\n.kbd { font: inherit; color: color-mix(in oklab, var(--background) 58%, var(--foreground)); font-variant-numeric: tabular-nums; }\n\n/* Side tooltips for a column match the library tooltip, rising a few pixels away from the rail instead of up. */\n.sideTip { --tip-x: -3px; z-index: var(--layer-tooltip); max-width: 15rem; padding: var(--space-group) var(--space-inset); border: 1px solid color-mix(in oklab, var(--background) 14%, var(--foreground)); border-radius: var(--radius-control); corner-shape: var(--corner-shape); background: var(--foreground); color: var(--background); box-shadow: var(--shadow-raised); font-family: var(--font-body); font-size: var(--text-sm); font-weight: 400; letter-spacing: var(--tracking-body); line-height: var(--leading-body); transform-origin: var(--radix-tooltip-content-transform-origin); transition: opacity var(--duration-fast) var(--ease-enter), transform var(--duration-fast) var(--ease-enter); }\n.sideTip[data-side=\"left\"] { --tip-x: 3px; }\n@starting-style {\n  .sideTip[data-state$=\"-open\"] { opacity: 0; transform: translateX(var(--tip-x)) scale(.97); }\n}\n/* Radix unmounts when the exit animation ends, so a no-op keyframe times the exit while the transition does the visual work. */\n.sideTip[data-state=\"closed\"] { opacity: 0; transform: scale(.98); transition: opacity 110ms var(--ease-standard), transform 110ms var(--ease-standard); animation: side-tip-exit 110ms linear both; }\n@keyframes side-tip-exit { to { --side-tip-exit: 1; } }\n\n@media (prefers-reduced-motion: reduce) {\n  .item, .highlight { transition: color var(--duration-instant) linear, background-color var(--duration-instant) linear, opacity var(--duration-instant) linear; }\n  .sideTip, .sideTip[data-state=\"closed\"] { transform: none; transition: opacity var(--duration-fast) linear; }\n}\n"},{"path":"registry/components/tooltip/tooltip.tsx","type":"registry:component","target":"@components/silicon-ui/tooltip/tooltip.tsx","content":"\"use client\";\n\nimport { useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore } from \"react\";\nimport type { ReactElement, ReactNode } from \"react\";\nimport * as TooltipPrimitive from \"@radix-ui/react-tooltip\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { motionTokens } from \"../lib/motion-tokens\";\nimport styles from \"./tooltip.module.css\";\n\nexport interface TooltipProps {\n  content: ReactNode;\n  children: ReactElement;\n  side?: \"top\" | \"bottom\" | \"left\" | \"right\";\n}\n\nconst DELAY = 250;\nconst SKIP_WINDOW = 300;\n\n/* Every Tooltip brings its own provider, so the skip window is shared here: while any tooltip is open, and briefly after the last one closes, the next opens without delay or travel. */\nlet warm = false;\nlet openCount = 0;\nlet coolTimer = 0;\nconst listeners = new Set<() => void>();\nconst warmth = {\n  subscribe(listener: () => void) { listeners.add(listener); return () => { listeners.delete(listener); }; },\n  get: () => warm,\n  set(next: boolean) { if (warm === next) return; warm = next; listeners.forEach(listener => listener()); },\n  opened() { openCount += 1; window.clearTimeout(coolTimer); warmth.set(true); },\n  closed() { openCount = Math.max(0, openCount - 1); if (openCount) return; window.clearTimeout(coolTimer); coolTimer = window.setTimeout(() => warmth.set(false), SKIP_WINDOW); },\n};\n\n/** String content crossfades when it changes while open, and the bubble springs to the new text size. */\nfunction TooltipText({ text }: { text: string }) {\n  const reduced = useReducedMotion();\n  const measure = useRef<HTMLSpanElement>(null);\n  const measured = useRef<string | null>(null);\n  const [size, setSize] = useState<{ width: number; height: number; animate: boolean } | null>(null);\n  useLayoutEffect(() => {\n    const node = measure.current;\n    if (!node) return;\n    const observer = new ResizeObserver(([entry]) => {\n      if (!entry) return;\n      const box = entry.borderBoxSize?.[0];\n      const current = node.textContent;\n      const animate = measured.current !== null && measured.current !== current;\n      measured.current = current;\n      setSize({ width: Math.ceil(box?.inlineSize ?? node.offsetWidth), height: Math.ceil(box?.blockSize ?? node.offsetHeight), animate });\n    });\n    observer.observe(node);\n    return () => observer.disconnect();\n  }, []);\n  return <motion.span className={styles.text} initial={false} animate={size ? { width: size.width, height: size.height } : undefined} transition={size?.animate && !reduced ? motionTokens.spring.morph : { duration: 0 }}>\n    <span ref={measure} className={styles.measure} aria-hidden=\"true\">{text}</span>\n    <AnimatePresence mode=\"popLayout\" initial={false}>\n      <motion.span key={text} className={styles.line} initial={reduced ? false : { opacity: 0, y: \"0.3em\", filter: `blur(${motionTokens.blur.soft}px)` }} animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }} exit={reduced ? { opacity: 0, transition: { duration: 0 } } : { opacity: 0, y: \"-0.3em\", filter: `blur(${motionTokens.blur.subtle}px)`, transition: { duration: motionTokens.duration.instant, ease: [...motionTokens.ease.standard] } }} transition={{ duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter] }}>{text}</motion.span>\n    </AnimatePresence>\n  </motion.span>;\n}\n\nexport function Tooltip({ content, children, side = \"top\" }: TooltipProps) {\n  const isWarm = useSyncExternalStore(warmth.subscribe, warmth.get, () => false);\n  // Controlled so the instant flag lands in the same render that mounts the content (Radix reports uncontrolled changes a frame late).\n  const [open, setOpen] = useState(false);\n  const [instant, setInstant] = useState(false);\n  useEffect(() => {\n    if (!open) return;\n    warmth.opened();\n    return warmth.closed;\n  }, [open]);\n  return <TooltipPrimitive.Provider delayDuration={DELAY} skipDelayDuration={0}>\n    <TooltipPrimitive.Root open={open} delayDuration={isWarm ? 0 : DELAY} onOpenChange={next => { if (next) setInstant(warmth.get()); setOpen(next); }}>\n      <TooltipPrimitive.Trigger asChild>{children}</TooltipPrimitive.Trigger>\n      <TooltipPrimitive.Portal>\n        <TooltipPrimitive.Content className={styles.tooltip} data-instant={instant || undefined} side={side} sideOffset={8} collisionPadding={12}>\n          {typeof content === \"string\" || typeof content === \"number\" ? <TooltipText text={String(content)}/> : content}\n        </TooltipPrimitive.Content>\n      </TooltipPrimitive.Portal>\n    </TooltipPrimitive.Root>\n  </TooltipPrimitive.Provider>;\n}\n\nexport default Tooltip;\n"},{"path":"registry/components/tooltip/tooltip.module.css","type":"registry:component","target":"@components/silicon-ui/tooltip/tooltip.module.css","content":".tooltip { --tooltip-y: 3px; z-index: var(--layer-tooltip); max-width: 15rem; padding: var(--space-group) var(--space-inset); border: 1px solid color-mix(in oklab, var(--background) 14%, var(--foreground)); border-radius: var(--radius-control); background: var(--foreground); color: var(--background); box-shadow: var(--shadow-raised); font-size: var(--text-sm); font-weight: 400; line-height: var(--leading-body); transform-origin: var(--radix-tooltip-content-transform-origin); transition: opacity var(--duration-fast) var(--ease-enter), transform var(--duration-fast) var(--ease-enter); }\n.tooltip[data-side=\"bottom\"] { --tooltip-y: -3px; }\n/* The first tooltip waits, then rises a few pixels from its trigger. Within the skip window the next one only fades.\n   Transitions instead of keyframes, so returning to the trigger while it fades out reverses the fade instead of restarting it. */\n@starting-style {\n  .tooltip[data-state$=\"-open\"] { opacity: 0; transform: translateY(var(--tooltip-y)) scale(.97); }\n  .tooltip[data-instant][data-state$=\"-open\"] { transform: none; }\n}\n.tooltip[data-instant][data-state$=\"-open\"] { transition: opacity var(--duration-instant) var(--ease-standard), transform var(--duration-instant) var(--ease-standard); }\n/* Radix unmounts when the exit animation ends, so a no-op keyframe times the exit while the transition does the visual work. */\n.tooltip[data-state=\"closed\"] { opacity: 0; transform: scale(.98); transition: opacity var(--duration-instant) var(--ease-standard), transform var(--duration-instant) var(--ease-standard); animation: tooltip-exit var(--duration-instant) linear both; }\n.text { position: relative; display: block; overflow: clip; overflow-clip-margin: var(--space-related); }\n.measure { position: absolute; top: 0; left: 0; width: max-content; max-width: calc(15rem - 2 * var(--space-inset) - 2px); visibility: hidden; pointer-events: none; }\n.line { display: block; width: max-content; max-width: calc(15rem - 2 * var(--space-inset) - 2px); }\n@keyframes tooltip-exit { to { --tooltip-exit: 1; } }\n@media (prefers-reduced-motion: reduce) {\n  .tooltip, .tooltip[data-state=\"closed\"], .tooltip[data-instant][data-state$=\"-open\"] { transform: none; transition: opacity var(--duration-instant) var(--ease-standard); }\n}\n"}],"docs":"Docs and live preview: https://ui.teamofsilicons.com/floating-button-group","meta":{"tier":"free","kind":"component","docs":"https://ui.teamofsilicons.com/floating-button-group","markdown":"https://ui.teamofsilicons.com/floating-button-group.md","tags":["action","group","toolbar","new"],"status":"stable"}}