JAVASCRIPT · PRACTICAL TUTORIAL

How to Synchronize Scrolling Between Two Elements

Build a bidirectional scroll link that handles different content heights without taking over the visitor’s input.

Two miniature browser panels have matching highlighted content and linked scrollbar handles.
Conceptual illustration accompanying this article.

Two scrollable panes can be useful for comparing text, reading notes beside a document or inspecting related tables. If the panes need to move together, the first idea is often to copy one element’s scroll position to the other. That works only when identical pixel positions mean the same thing in both panes. Different content heights reveal the limitation quickly.

This tutorial links the panes by scroll progress instead. It also handles the programmatic scroll events created by synchronization and returns a cleanup function for removing the listeners. The method is a practical starting point, not a promise that two unrelated documents will line up paragraph by paragraph. You can open the standalone scrolling demo to try the interaction.

Choose what synchronized means

For identical tables or mirrored content, copying scrollTop may be the right behavior. If one pane has more content, matching its percentage of available scroll distance is often more useful. Calculate that distance as scrollHeight - clientHeight, then divide the current scroll position by it.

The resulting ratio can be multiplied by the other pane’s available distance. Halfway down a pane with an eight-hundred-pixel range becomes halfway down one with a twelve-hundred-pixel range. That means four hundred pixels in the first pane and six hundred in the second. It is a proportional mapping, not a comparison of the text itself.

If corresponding sections must align exactly, introduce explicit shared anchors and map progress within those sections. Whole-document percentages can drift when a figure, expanded explanation or different font changes only one side. Defining the required relationship before writing listeners avoids solving the wrong problem.

Keep the panes independently scrollable

Give each pane a constrained height and overflow-y: auto. In the demo, each is a labeled region that can receive keyboard focus. Native scrolling still does the work; the script reacts to its result. There is no reason to intercept wheel gestures or replace ordinary touch behavior.

<div id="pane-a" class="panel" tabindex="0"
     role="region" aria-label="Scrollable example pane A">...</div>
<div id="pane-b" class="panel" tabindex="0"
     role="region" aria-label="Scrollable example pane B">...</div>

Use stable IDs or classes when selecting the elements, and initialize only after they exist. The demo loads the script below the markup. A shared site script can use deferred loading instead. Keep the content available when JavaScript is unavailable: synchronization is an enhancement rather than a prerequisite for reading.

Avoid a programmatic feedback loop

Changing the right pane’s scroll position can create a scroll event on that pane. A naive right-side handler then writes to the left pane again. An ordinary Boolean reset immediately after assignment is not a reliable guard, because event delivery can occur later. Track the position you expect the script to produce instead.

The implementation records the target value in a weak map, then ignores a resulting event if the actual position matches within a small tolerance. If a user has moved to a different position, that event becomes a new driver. The tolerance accommodates fractional positions and rounding; it is not intended to measure exact semantic alignment.

Use the complete synchronization helper

The helper accepts two elements and returns refresh() and destroy(). It uses direct scroll assignment and assumes no smooth-scrolling or scroll-snap behavior on these panes. You can download the JavaScript helper and adapt it to your own component.

function linkScrollPanels(left, right) {
  if (!left || !right || left === right) {
    throw new Error('Provide two different scroll panels');
  }
  const expected = new WeakMap();
  let lastDriver = left;
  const range = panel => Math.max(0, panel.scrollHeight - panel.clientHeight);

  function sync(source, target) {
    const sourceRange = range(source);
    const ratio = sourceRange > 0
      ? Math.max(0, Math.min(1, source.scrollTop / sourceRange)) : 0;
    const desired = ratio * range(target);
    if (Math.abs(target.scrollTop - desired) < 0.75) return;
    expected.set(target, desired);
    target.scrollTop = desired;
    // Use the browser's actual value if it clamps the assignment.
    expected.set(target, target.scrollTop);
  }

  function handle(source, target) {
    if (expected.has(source)) {
      const value = expected.get(source);
      expected.delete(source);
      if (Math.abs(source.scrollTop - value) < 0.75) return;
    }
    lastDriver = source;
    sync(source, target);
  }
  const onLeft = () => handle(left, right);
  const onRight = () => handle(right, left);
  left.addEventListener('scroll', onLeft, {passive: true});
  right.addEventListener('scroll', onRight, {passive: true});
  sync(left, right);
  return {
    refresh() { sync(lastDriver, lastDriver === left ? right : left); },
    destroy() {
      left.removeEventListener('scroll', onLeft);
      right.removeEventListener('scroll', onRight);
      expected.delete(left);
      expected.delete(right);
    }
  };
}

if (typeof module !== 'undefined' && module.exports) {
  module.exports = {linkScrollPanels};
}

The range calculation never becomes negative, and the ratio is clamped between zero and one. That protects the basic calculation from overscroll values and panes with no scrollable content. A non-scrollable source has a ratio of zero in this implementation; decide whether that convention matches the way you want your component to refresh.

Refresh after layout changes

A pane’s content height can change when an image finishes loading, text expands or the viewport changes width. The helper reads current dimensions whenever a scroll event occurs. Calling refresh() additionally reapplies the last driver’s position immediately after a layout change.

The demo listens for window resizing. An application with changing inner content may also observe an inner wrapper using ResizeObserver or refresh after its own update completes. Watching only a fixed-height outer pane will not necessarily detect changes in the height of its overflowing contents. Connect the refresh to the element or event that actually changes.

Give readers a way to work independently

The standalone demo includes a button to switch linking off and on. Disabling it calls destroy(), which removes both listeners. Enabling it creates a new connection with the left pane as the initial driver. That starting convention is explicit so readers understand why the other pane may move when linking is restored.

Keep focus on the element the user is operating. The synchronization helper should never move focus into the other pane merely because it changed that pane’s scroll position. Also avoid announcing every scroll update through a live region. Linked movement is a visual aid, and repeated announcements would make ordinary navigation unnecessarily noisy.

Test the boundaries of the interaction

Check both directions, the beginning and end of each pane, different heights and a pane with no overflow. Then disable the link and confirm that each pane stays independent. If the component is removed and recreated, make sure old listeners are cleaned up rather than accumulating a second synchronization system on the same elements.

Programmatic tests can verify the arithmetic and event suppression, but browser behavior still deserves a real keyboard, touch and pointer check. Continue with event listeners and pointer and focus interactions. MDN documents scroll events, scrollTop and ResizeObserver for the browser details behind this component.