{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "spring-tooltip",
  "type": "registry:ui",
  "title": "Spring Tooltip",
  "description": "One tooltip shared by a toolbar of icon buttons. It unfurls from a nub at the button, then travels: moving to a neighbour slides it across and resizes it on critically damped springs while a soft mask wipes the old label out and the new one in, and a fast sweep keeps its velocity instead of restarting. GSAP ticker, no provider.",
  "dependencies": [
    "gsap",
    "@gsap/react"
  ],
  "registryDependencies": [],
  "cssVars": {
    "theme": {}
  },
  "files": [
    {
      "path": "components/tweenui/spring-tooltip.tsx",
      "type": "registry:component",
      "target": "components/tweenui/spring-tooltip.tsx",
      "content": "'use client';\n\nimport {\n  useId,\n  useRef,\n  type ComponentPropsWithoutRef,\n  type CSSProperties,\n  type FocusEvent,\n  type MouseEventHandler,\n  type PointerEvent,\n  type ReactNode,\n} from 'react';\nimport { useGSAP } from '@gsap/react';\nimport gsap from 'gsap';\nimport { cn } from '@/lib/utils';\n\ngsap.registerPlugin(useGSAP);\n\nconst SLIDE_PULL = 320;\nconst WIDTH_PULL = 420;\nconst GAP = 10;\nconst EDGE = 8;\nconst NUB = 28;\nconst ARROW_INSET = 12;\nconst REVEALED = 112;\nconst DRIFT = 8;\nconst SWEEP_RIGHT = '105deg';\nconst SWEEP_LEFT = '285deg';\n\n// --tip-reveal runs 0 to 112: the soft edge is 12% wide, so 112 clears it past\n// the far side and the label shows in full. The 15° tilt off a flat sweep is\n// the same lean as the glint's skew, so the light rides the edge.\nconst MASK =\n  'linear-gradient(var(--tip-dir), #000 calc(var(--tip-reveal) * 1% - 12%), transparent calc(var(--tip-reveal) * 1%))';\n\nconst LABEL_STYLE = {\n  '--tip-dir': SWEEP_RIGHT,\n  '--tip-reveal': REVEALED,\n  maskImage: MASK,\n  WebkitMaskImage: MASK,\n} as CSSProperties;\n\nconst TRIGGER_CLASS =\n  'relative z-10 grid size-10 cursor-pointer place-items-center rounded-lg border-0 bg-transparent text-[#045f64] outline-none transition-colors duration-200 ease-out hover:bg-[#045f64]/10 data-[highlighted=true]:bg-[#045f64]/10 focus-visible:ring-2 focus-visible:ring-[#045f64] disabled:cursor-not-allowed disabled:opacity-40 motion-reduce:transition-none [&_svg]:size-[18px]';\n\nconst prefersReducedMotion = () =>\n  typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;\n\nconst isFocusVisible = (el: Element) => {\n  try {\n    return el.matches(':focus-visible');\n  } catch {\n    return true;\n  }\n};\n\nconst clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max);\n\ninterface Spring {\n  x: number;\n  v: number;\n  t: number;\n}\n\nconst spring = (): Spring => ({ x: 0, v: 0, t: 0 });\n\nconst snap = (target: Spring) => {\n  target.x = target.t;\n  target.v = 0;\n};\n\nconst stepSpring = (target: Spring, stiffness: number, dt: number) => {\n  const force = stiffness * (target.t - target.x) - 2 * Math.sqrt(stiffness) * target.v;\n  target.v += force * dt;\n  target.x += target.v * dt;\n};\n\nconst atRest = (target: Spring, epsilon: number) =>\n  Math.abs(target.t - target.x) < epsilon && Math.abs(target.v) < epsilon;\n\n// The tooltip is absolutely positioned inside the group, so the viewport edges\n// are clamped in viewport space and then converted to the group's coordinates.\nconst placeTooltip = (trigger: HTMLElement, group: HTMLElement, width: number, height: number) => {\n  const box = trigger.getBoundingClientRect();\n  const origin = group.getBoundingClientRect();\n  const roomAbove = box.top - GAP - height >= EDGE;\n  const roomBelow = box.bottom + GAP + height <= window.innerHeight - EDGE;\n  const side: 'top' | 'bottom' = roomAbove || !roomBelow ? 'top' : 'bottom';\n  const anchor = box.left + box.width / 2;\n  const half = width / 2;\n  const center = clamp(anchor, EDGE + half, Math.max(EDGE + half, window.innerWidth - EDGE - half));\n  return {\n    side,\n    x: center - origin.left,\n    anchor: anchor - origin.left,\n    y: side === 'top' ? box.top - origin.top - GAP - height : box.bottom - origin.top + GAP,\n  };\n};\n\nexport interface SpringTooltipItem {\n  label: string;\n  icon: ReactNode;\n  shortcut?: string;\n  onClick?: MouseEventHandler<HTMLButtonElement>;\n  disabled?: boolean;\n}\n\ninterface SpringTooltipProps extends ComponentPropsWithoutRef<'div'> {\n  items: SpringTooltipItem[];\n  delay?: number;\n  closeDelay?: number;\n}\n\nconst SpringTooltip = ({\n  items,\n  delay = 150,\n  closeDelay = 300,\n  className,\n  ...props\n}: SpringTooltipProps) => {\n  const tooltipId = useId();\n  const groupRef = useRef<HTMLDivElement>(null);\n  const tipRef = useRef<HTMLDivElement>(null);\n  const bodyRef = useRef<HTMLDivElement>(null);\n  const boxRef = useRef<HTMLDivElement>(null);\n  const arrowRef = useRef<HTMLSpanElement>(null);\n  const glintRef = useRef<HTMLSpanElement>(null);\n  const motion = useRef({ x: spring(), anchor: spring(), width: spring(), y: 0, awake: false });\n  const state = useRef({\n    active: -1,\n    pending: -1,\n    dismissed: -1,\n    open: false,\n    shown: false,\n    hovering: false,\n    openTimer: 0,\n    closeTimer: 0,\n  });\n\n  const triggers = () =>\n    Array.from(groupRef.current?.querySelectorAll<HTMLElement>('[data-tip-trigger]') ?? []);\n\n  const labels = () =>\n    Array.from(tipRef.current?.querySelectorAll<HTMLElement>('[data-tip-label]') ?? []);\n\n  const indexOf = (target: EventTarget | null) => {\n    const trigger = (target as HTMLElement | null)?.closest?.('[data-tip-trigger]');\n    return trigger instanceof HTMLElement ? Number(trigger.dataset.index) : -1;\n  };\n\n  const stopTimers = () => {\n    const s = state.current;\n    window.clearTimeout(s.openTimer);\n    window.clearTimeout(s.closeTimer);\n    s.openTimer = 0;\n    s.closeTimer = 0;\n    s.pending = -1;\n  };\n\n  const draw = () => {\n    const tip = tipRef.current;\n    const box = boxRef.current;\n    const arrow = arrowRef.current;\n    if (!tip || !box || !arrow) return;\n    const m = motion.current;\n    const width = Math.max(m.width.x, 0);\n    const left = m.x.x - width / 2;\n    tip.style.transform = `translate3d(${left}px,${m.y}px,0)`;\n    box.style.width = `${width}px`;\n    arrow.style.left = `${clamp(m.anchor.x - left, ARROW_INSET, Math.max(ARROW_INSET, width - ARROW_INSET))}px`;\n  };\n\n  const aim = (index: number, settle: boolean) => {\n    const group = groupRef.current;\n    const tip = tipRef.current;\n    const box = boxRef.current;\n    const trigger = triggers()[index];\n    if (!group || !tip || !box || !trigger) return;\n    const m = motion.current;\n    const width = labels()[index]?.offsetWidth ?? 0;\n    const next = placeTooltip(trigger, group, width, box.offsetHeight);\n    tip.dataset.side = next.side;\n    m.y = next.y;\n    m.x.t = next.x;\n    m.anchor.t = next.anchor;\n    m.width.t = width;\n    if (settle) {\n      [m.x, m.anchor, m.width].forEach(snap);\n      m.awake = false;\n      draw();\n      return;\n    }\n    m.awake = true;\n  };\n\n  const highlight = (index: number) => {\n    triggers().forEach((trigger, i) => {\n      trigger.dataset.highlighted = i === index ? 'true' : 'false';\n    });\n  };\n\n  // Each button is already named by its aria-label, so the label text stays\n  // hidden from assistive tech and only the shortcut reaches the description.\n  const expose = (index: number) => {\n    labels().forEach((label, i) => {\n      if (i === index) label.removeAttribute('aria-hidden');\n      else label.setAttribute('aria-hidden', 'true');\n    });\n  };\n\n  // The label stays centred by its Tailwind translate, so the mask and drift\n  // go on an inner span: tweening x on the label itself would take over its\n  // transform and drop the centring.\n  const inkOf = (label: HTMLElement) => label.firstElementChild as HTMLElement;\n\n  const showLabel = (from: number, to: number, animate: boolean) => {\n    const all = labels();\n    const next = all[to];\n    const prev = from === to ? undefined : all[from];\n    const wiping = animate && prev && !gsap.isTweening(inkOf(prev));\n    expose(to);\n\n    all.forEach((label) => {\n      if (animate && label === prev) return;\n      gsap.killTweensOf([label, inkOf(label)]);\n      gsap.set(label, { autoAlpha: label === next ? 1 : 0 });\n      gsap.set(inkOf(label), { x: 0, '--tip-reveal': REVEALED });\n    });\n    if (!animate || !next) return;\n\n    // One slanted edge sweeps the way the tooltip travels, with a glint riding\n    // it: it erases the old label and draws the new one in behind it. A label\n    // caught mid-wipe by a fast sweep just fades, since flipping its mask\n    // would jump.\n    const forward = from < 0 || to > from;\n    const step = forward ? 1 : -1;\n    const wipe = { duration: 0.42, ease: 'power2.inOut', overwrite: true };\n    if (prev && wiping) {\n      const ink = inkOf(prev);\n      gsap.set(ink, { '--tip-dir': forward ? SWEEP_LEFT : SWEEP_RIGHT });\n      gsap.to(ink, {\n        '--tip-reveal': 0,\n        x: DRIFT * step,\n        ...wipe,\n        onComplete: () => {\n          gsap.set(prev, { autoAlpha: 0 });\n          gsap.set(ink, { x: 0 });\n        },\n      });\n    } else if (prev) {\n      gsap.to(prev, { autoAlpha: 0, duration: 0.12, overwrite: true });\n    }\n    gsap.fromTo(\n      inkOf(next),\n      { '--tip-dir': forward ? SWEEP_RIGHT : SWEEP_LEFT, '--tip-reveal': 0, x: -DRIFT * step },\n      { '--tip-reveal': REVEALED, x: 0, ...wipe }\n    );\n\n    const glint = glintRef.current;\n    if (!glint) return;\n    gsap.to(glint, {\n      keyframes: { left: forward ? ['-8%', '108%'] : ['108%', '-8%'], opacity: [0, 1, 1, 0] },\n      ...wipe,\n    });\n  };\n\n  const { contextSafe } = useGSAP(\n    () => {\n      const body = bodyRef.current;\n      if (!body) return;\n      const s = state.current;\n      const m = motion.current;\n      Object.assign(s, { active: -1, dismissed: -1, open: false, shown: false });\n      gsap.set(body, { autoAlpha: 0, scale: 0.96 });\n      gsap.set(labels(), { autoAlpha: 0 });\n\n      const tick = (_time: number, deltaTime: number) => {\n        if (!m.awake) return;\n        const elapsed = Math.min(deltaTime / 1000, 0.05);\n        const steps = Math.max(1, Math.ceil(elapsed * 120));\n        const dt = elapsed / steps;\n        for (let step = 0; step < steps; step += 1) {\n          stepSpring(m.x, SLIDE_PULL, dt);\n          stepSpring(m.anchor, SLIDE_PULL, dt);\n          stepSpring(m.width, WIDTH_PULL, dt);\n        }\n        if ([m.x, m.anchor, m.width].every((item) => atRest(item, 0.05))) {\n          [m.x, m.anchor, m.width].forEach(snap);\n          m.awake = false;\n        }\n        draw();\n      };\n\n      const onKeyDown = (event: KeyboardEvent) => {\n        if (event.key !== 'Escape' || (!s.open && s.pending < 0)) return;\n        s.dismissed = s.open ? s.active : s.pending;\n        conceal();\n      };\n\n      const onViewport = () => {\n        if (s.open) aim(s.active, !m.awake);\n      };\n\n      gsap.ticker.add(tick);\n      document.addEventListener('keydown', onKeyDown);\n      window.addEventListener('resize', onViewport);\n      window.addEventListener('scroll', onViewport, { capture: true, passive: true });\n      return () => {\n        stopTimers();\n        gsap.ticker.remove(tick);\n        document.removeEventListener('keydown', onKeyDown);\n        window.removeEventListener('resize', onViewport);\n        window.removeEventListener('scroll', onViewport, { capture: true });\n      };\n    },\n    { scope: groupRef, dependencies: [items] }\n  );\n\n  const reveal = contextSafe((index: number) => {\n    const tip = tipRef.current;\n    const body = bodyRef.current;\n    if (!tip || !body) return;\n    stopTimers();\n    const s = state.current;\n    const reduced = prefersReducedMotion();\n    const from = s.active;\n    const wasShown = s.shown;\n    const sliding = wasShown && !reduced;\n    s.open = true;\n    s.shown = true;\n    s.active = index;\n    tip.dataset.state = 'open';\n    highlight(index);\n    aim(index, !sliding);\n    showLabel(wasShown ? from : -1, index, !reduced);\n\n    gsap.killTweensOf(body);\n    if (reduced) {\n      gsap.set(body, { autoAlpha: 1, scale: 1, y: 0, filter: 'blur(0px)' });\n      return;\n    }\n    if (!wasShown) {\n      const m = motion.current;\n      m.width.x = Math.min(NUB, m.width.t);\n      m.awake = true;\n      draw();\n      gsap.set(body, { y: tip.dataset.side === 'top' ? 6 : -6, filter: 'blur(4px)' });\n    }\n    gsap.to(body, {\n      autoAlpha: 1,\n      scale: 1,\n      y: 0,\n      filter: 'blur(0px)',\n      duration: 0.24,\n      ease: 'power3.out',\n    });\n  });\n\n  const conceal = contextSafe(() => {\n    const tip = tipRef.current;\n    const body = bodyRef.current;\n    stopTimers();\n    const s = state.current;\n    if (!tip || !body || !s.open) return;\n    s.open = false;\n    tip.dataset.state = 'closed';\n    highlight(-1);\n\n    const settle = () => {\n      s.shown = false;\n      motion.current.awake = false;\n    };\n    gsap.killTweensOf(body);\n    if (prefersReducedMotion()) {\n      gsap.set(body, { autoAlpha: 0 });\n      settle();\n      return;\n    }\n    const m = motion.current;\n    m.width.t = Math.min(NUB, m.width.t);\n    m.awake = true;\n    gsap.to(body, {\n      autoAlpha: 0,\n      y: tip.dataset.side === 'top' ? 4 : -4,\n      scale: 0.97,\n      filter: 'blur(3px)',\n      duration: 0.22,\n      ease: 'power2.inOut',\n      onComplete: settle,\n    });\n  });\n\n  const request = (index: number) => {\n    const s = state.current;\n    window.clearTimeout(s.closeTimer);\n    s.closeTimer = 0;\n    if (index < 0 || index === s.dismissed) return;\n    s.dismissed = -1;\n    if (s.open) {\n      if (index !== s.active) reveal(index);\n      return;\n    }\n    s.pending = index;\n    if (s.openTimer) return;\n    s.openTimer = window.setTimeout(() => {\n      s.openTimer = 0;\n      if (s.pending >= 0) reveal(s.pending);\n    }, delay);\n  };\n\n  const release = () => {\n    const s = state.current;\n    window.clearTimeout(s.openTimer);\n    s.openTimer = 0;\n    s.pending = -1;\n    if (!s.open || s.closeTimer) return;\n    s.closeTimer = window.setTimeout(conceal, closeDelay);\n  };\n\n  const onPointerOver = (event: PointerEvent<HTMLDivElement>) => {\n    if (event.pointerType === 'touch') return;\n    state.current.hovering = true;\n    request(indexOf(event.target));\n  };\n\n  const onPointerLeave = (event: PointerEvent<HTMLDivElement>) => {\n    if (event.pointerType === 'touch') return;\n    const s = state.current;\n    s.hovering = false;\n    s.dismissed = -1;\n    const focused = triggers().findIndex(\n      (trigger) => trigger === document.activeElement && isFocusVisible(trigger)\n    );\n    if (focused >= 0) request(focused);\n    else release();\n  };\n\n  const onFocus = (event: FocusEvent<HTMLDivElement>) => {\n    const index = indexOf(event.target);\n    if (index < 0 || !isFocusVisible(event.target)) return;\n    if (!state.current.open) expose(index);\n    request(index);\n  };\n\n  const onBlur = (event: FocusEvent<HTMLDivElement>) => {\n    if (event.currentTarget.contains(event.relatedTarget as Node | null)) return;\n    if (!state.current.hovering) release();\n  };\n\n  return (\n    <div\n      {...props}\n      ref={groupRef}\n      role=\"group\"\n      aria-label={props['aria-label'] ?? 'Toolbar'}\n      className={cn(\n        'relative inline-flex items-center gap-1 rounded-xl border border-[#045f64]/15 bg-white p-1 shadow-[0_1px_2px_rgba(16,24,40,0.08)]',\n        className\n      )}\n      onPointerOver={onPointerOver}\n      onPointerLeave={onPointerLeave}\n      onFocus={onFocus}\n      onBlur={onBlur}\n    >\n      {items.map((item, index) => (\n        <button\n          key={`${index}-${item.label}`}\n          type=\"button\"\n          data-tip-trigger\n          data-index={index}\n          data-highlighted=\"false\"\n          aria-label={item.label}\n          aria-describedby={tooltipId}\n          disabled={item.disabled}\n          onClick={item.onClick}\n          className={TRIGGER_CLASS}\n        >\n          {item.icon}\n        </button>\n      ))}\n      <div\n        ref={tipRef}\n        data-tip\n        data-state=\"closed\"\n        data-side=\"top\"\n        className=\"group pointer-events-none absolute top-0 left-0 z-20 will-change-transform\"\n      >\n        <div\n          ref={bodyRef}\n          id={tooltipId}\n          role=\"tooltip\"\n          className=\"invisible relative origin-bottom opacity-0 group-data-[side=bottom]:origin-top\"\n        >\n          <span\n            ref={arrowRef}\n            aria-hidden=\"true\"\n            className=\"absolute size-2.5 -translate-x-1/2 rotate-45 rounded-[2px] bg-[#0b3b3e] group-data-[side=bottom]:-top-1 group-data-[side=top]:-bottom-1\"\n          />\n          <div\n            ref={boxRef}\n            className=\"relative h-8 overflow-hidden rounded-lg bg-[#0b3b3e] text-white shadow-[0_8px_20px_-8px_rgba(4,47,50,0.55)]\"\n          >\n            {items.map((item, index) => (\n              <span\n                key={`${index}-${item.label}`}\n                data-tip-label={item.label}\n                aria-hidden=\"true\"\n                className=\"invisible absolute top-0 left-1/2 h-full w-max -translate-x-1/2 opacity-0\"\n              >\n                <span\n                  style={LABEL_STYLE}\n                  className=\"flex h-full items-center gap-2 px-2.5 font-[family-name:Outfit,sans-serif] text-[13px] whitespace-nowrap\"\n                >\n                  <span aria-hidden=\"true\">{item.label}</span>\n                  {item.shortcut && (\n                    <kbd className=\"rounded bg-white/10 px-1.5 py-px font-sans text-[11px] text-[#c6f56f]\">\n                      {item.shortcut}\n                    </kbd>\n                  )}\n                </span>\n              </span>\n            ))}\n            <span\n              ref={glintRef}\n              aria-hidden=\"true\"\n              className=\"pointer-events-none absolute top-0 left-0 h-full w-5 -translate-x-1/2 -skew-x-[15deg] bg-[linear-gradient(90deg,transparent,rgba(198,245,111,0.55),transparent)] opacity-0\"\n            />\n          </div>\n        </div>\n      </div>\n    </div>\n  );\n};\n\nexport default SpringTooltip;\n"
    }
  ],
  "docs": "Docs, props and examples: https://tween-ui.vercel.app/component/spring-tooltip\nThe component renders the buttons itself from an items array, so one tooltip can serve every trigger without a context provider."
}