title: How to observe DOM element position changes
body: <p>I need to observe a DOM element position as I need to show a popup panel relative to it (but not in the same container) and the panel should follow the element. How I should implement such logic?</p>
  
  <p>Here is a snippet where you can see the opening of outer and nested popup panels, but they do not follow the horizontal scroll. I want them both to follow it and keep showing near the corresponding icon (and it should be a generic approach that will work in any place). You may ignore that nested popup is not closed together with outer - it's just to make the snippet simpler. I expect no changes except the <code>showPopup</code> function. Markup is specially simplified for this example; do not try to change it - I need it as it is.</p>
  
  <p><div class="snippet" data-lang="js" data-hide="false" data-console="true" data-babel="false">
  <div class="snippet-code">
  <pre class="snippet-code-js lang-js prettyprint-override"><code>~function handlePopups() {
    function showPopup(src, popup, popupContainer) {
      var bounds = popupContainer.getBoundingClientRect()
      var bb = src.getBoundingClientRect()
  
      popup.style.left = bb.right - bounds.left - 1 + 'px'
      popup.style.top = bb.bottom - bounds.top - 1 + 'px'
  
      return () =&gt; {
        // fucntion to cleanup handlers when closed
      }
    }
  
    var opened = new Map()
  
    document.addEventListener('click', e =&gt; {
      if (e.target.tagName === 'I') {
        var wasActive = e.target.classList.contains('active')
        var popup = document.querySelector(`.popup[data-popup="${e.target.dataset.popup}"]`)
  
        var old = opened.get(popup)
  
        if (old) {
          old.src.classList.remove('active')
          popup.hidden = true
          old.close()
          opened.delete(old)
        }
  
        if (!wasActive) {
          e.target.classList.add('active')
          popup.hidden = false
  
          opened.set(popup, {
            src: e.target,
            close: showPopup(e.target, popup, document.querySelector('.popup-dest')),
          })
        }
      }
    })
  }()
  
  ~function syncParts() {
    var scrollLeft = 0
  
    document.querySelector('main').addEventListener('scroll', e =&gt; {
      if (e.target.classList.contains('inner') &amp;&amp; e.target.scrollLeft !== scrollLeft) {
        scrollLeft = e.target.scrollLeft
        void [...document.querySelectorAll('.middle .inner')]
             .filter(x =&gt; x.scrollLeft !== scrollLeft)
             .forEach(x =&gt; x.scrollLeft = scrollLeft)
      }
    }, true)
  }()</code></pre>
  <pre class="snippet-code-css lang-css prettyprint-override"><code>* {
    box-sizing: border-box;
  }
  
  [hidden] {
    display: none !important;
  }
  
  html, body, main {
    height: 100%;
    margin: 0;
  }
  
  main {
    display: grid;
    grid-template: auto 1fr 17px / auto 1fr auto;
  }
  
  section {
    overflow: hidden;
    display: flex;
    flex-direction: column;
    outline: 1px dotted red;
    outline-offset: -1px;
    position: relative;
  }
  
  .inner {
    overflow: scroll;
    padding: 0 1px 1px 0;
    margin: 0 -18px -18px 0;
    flex: 1 1 0px;
    display: flex;
    flex-direction: column;
  }
  
  .top {
    grid-row: 1;
  }
  
  .bottom {
    grid-row: 2;
  }
  
  .left {
    grid-column: 1;
  }
  
  .middle {
    grid-column: 2;
  }
  
  .right {
    grid-column: 3;
  }
  
  .wide, .scroller {
    width: 2000px;
    flex: 1 0 1px;
  }
  
  .wide {
    background: repeating-linear-gradient(to right, rgba(0,255,0,.5), rgba(0,0,255,.5) 16em);
  }
  
  .visible-scroll .inner {
    margin-top: -1px;
    margin-bottom: 0;
  }
  
  .scroller {
    height: 1px;
  }
  
  .popup-dest {
    pointer-events: none;
    grid-row: 1 / 3;
    position: relative;
  }
  
  .popup {
    position: absolute;
    border: 1px solid;
    pointer-events: all;
  }
  
  .popup-outer {
    width: 8em;
    height: 8em;
    background: silver;
  }
  
  .popup-nested {
    width: 5em;
    height: 5em;
    background: antiquewhite;
  }
  
  i {
    display: inline-block;
    border-radius: 50% 50% 0 50%;
    border: 1px solid;
    width: 1.5em;
    height: 1.5em;
    line-height: 1.5em;
    text-align: center;
    cursor: pointer;
  }
  
  i::after {
    content: "i";
  }
  
  i.active {
    background: rgba(255,255,255,.5);
  }</code></pre>
  <pre class="snippet-code-html lang-html prettyprint-override"><code>&lt;main&gt;
    &lt;section class="top left"&gt;
      &lt;div&gt;&lt;div class="inner"&gt;
        &lt;div&gt;Smth&lt;br&gt;here&lt;/div&gt;
      &lt;/div&gt;&lt;/div&gt;
    &lt;/section&gt;
    &lt;section class="top middle"&gt;
      &lt;div class="inner"&gt;
        &lt;div class="wide"&gt;
          &lt;i data-popup="outer" style="margin-left:10em"&gt;&lt;/i&gt;
          &lt;i data-popup="outer" style="margin-left:10em"&gt;&lt;/i&gt;
          &lt;i data-popup="outer" style="margin-left:10em"&gt;&lt;/i&gt;
          &lt;i data-popup="outer" style="margin-left:10em"&gt;&lt;/i&gt;
          &lt;i data-popup="outer" style="margin-left:10em"&gt;&lt;/i&gt;
          &lt;i data-popup="outer" style="margin-left:10em"&gt;&lt;/i&gt;
          &lt;i data-popup="outer" style="margin-left:10em"&gt;&lt;/i&gt;
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/section&gt;
    &lt;section class="top right"&gt;
      &lt;div class="inner"&gt;Smth here&lt;/div&gt;&lt;/section&gt;
    &lt;section class="bottom left"&gt;
      &lt;div class="inner"&gt;Smth here&lt;/div&gt;
    &lt;/section&gt;
    &lt;section class="bottom middle"&gt;
      &lt;div class="inner"&gt;
        &lt;div class="wide"&gt;&lt;script&gt;document.write("Smth is here too... ".repeat(1000))&lt;/script&gt;&lt;/div&gt;
      &lt;/div&gt;
    &lt;/section&gt;
    &lt;section class="bottom right"&gt;
      &lt;div class="inner"&gt;Smth here&lt;/div&gt;
    &lt;/section&gt;
    &lt;section class="middle visible-scroll"&gt;
      &lt;div class="inner"&gt;
        &lt;div class="scroller"&gt;&lt;/div&gt;
      &lt;/div&gt;
    &lt;/section&gt;
    &lt;section class="middle popup-dest"&gt;
      &lt;div class="popup popup-outer" data-popup="outer" hidden&gt;
        &lt;i  data-popup="nested" style="margin-left:5em;margin-top:5em;"&gt;&lt;/i&gt;
      &lt;/div&gt;
      &lt;div class="popup popup-nested" data-popup="nested" hidden&gt;
      &lt;/div&gt;
    &lt;/section&gt;
  &lt;/main&gt;</code></pre>
  </div>
  </div>
  </p>
  
  <p>Now I have following ideas:</p>
  
  <ul>
  <li><p>Listening to the <code>scroll</code> event on the <code>capturing</code> phase on body and getting the actual position of the element via <code>getBoundingClientRect</code> and the reposition panel according to the current location. I am currently using a similar solution, but there is an issue. When the element is moving by another script, it doesn't force panel repositioning. One of the cases - when the element itself is another panel - simple filtering of unrelated scroll events filters such scrolls out. Also I have some cases with debounce and they are difficult to handle too.</p></li>
  <li><p>Create <code>IntersectionObserver</code> to track moves. The problem seems to be in the fact that it only works on intersection size changes, not on any moves. I have an idea to crop viewport by <code>rootMargin</code> to the same rectangle that the element covers, but as options are readonly. It means I would need to create new observer on each move. I'm not sure about the performance impact of such a solution. Also as it provides only an approximate position, so I think that I can't eliminate calls to <code>getBoundingClientRect</code>.</p></li>
  <li><p>A hybrid solution as scrolls are usually taking some continuous time. Use the previous idea with <code>IntersectionObserver</code>, but when the first move is detected, just subscribe to <code>requestAnimationFrame</code> and check the element position there. While position differs, handle it and recursively use <code>requestAnimationFrame</code>. If the position is the same (I am not sure if one frame is enough, maybe in 5 frames?), stop subscribing <code>requestAnimationFrame</code> and create a new <code>IntersectionObserver</code>.</p></li>
  </ul>
  
  <p>I'm afraid that such solutions will have issues with performance. Also they seem to me too complex. Maybe there is some known solution which I should use?</p>