How do you prevent layout thrash when measuring and positioning a tooltip?

What’s up everyone? I’m wiring up a tooltip that needs to anchor to a button, and I’m trying to keep it from janking the page when it shows or when the window resizes. Right now it works, but I can see layout shifts and I’m pretty sure I’m doing read/write interleaving like a champ (in the worst way).

function positionTip(btn, tip) {
  tip.style.display = "block";
  const b = btn.getBoundingClientRect();
  tip.style.left = `${b.left}px`;
  tip.style.top = `${b.bottom + 8}px`;
  const t = tip.getBoundingClientRect();
  if (t.right > window.innerWidth) tip.style.left = `${window.innerWidth - t.width - 8}px`;
}
window.addEventListener("resize", () => positionTip(btn, tip));

How would you restructure this so reads are batched and layout stays stable, without making it overly complex or laggy on resize?

1 Like

The jank is mostly from flipping display and then doing read → write → read in the same call. Keep the tooltip “present” so you can measure it, then do one read phase and one write phase, and on resize only run once per frame.

I usually do position: fixed + top/left: 0 and move it with transform, with visibility: hidden while measuring so it doesn’t flash:

let rafId = 0;

function positionTip(btn, tip) {
  cancelAnimationFrame(rafId);
  rafId = requestAnimationFrame(() => {
    // Ensure it's measurable without affecting layout/flow
    tip.style.display = "block";
    tip.style.position = "fixed";
    tip.style.left = "0px";
    tip.style.top = "0px";
    tip.style.visibility = "hidden";

    // READS (batched)
    const b = btn.getBoundingClientRect();
    const t = tip.getBoundingClientRect();
    const vw = document.documentElement.clientWidth;
    const vh = document.documentElement.clientHeight;

    // COMPUTE
    const pad = 8;
    let x = b.left;
    let y = b.bottom + pad;

    x = Math.min(x, vw - t.width - pad);
    x = Math.max(pad, x);

    // optional: flip above if it would go off bottom
    if (y + t.height + pad > vh) y = b.top - t.height - pad;

    // WRITES (single commit)
    tip.style.transform = `translate3d(${Math.round(x)}px, ${Math.round(y)}px, 0)`;
    tip.style.visibility = "visible";
  });
}

window.addEventListener("resize", () => positionTip(btn, tip));

Two small details that help: don’t toggle display on/off every time (hide with visibility/opacity instead), and avoid that second getBoundingClientRect() after you’ve started writing styles.

1 Like

That display = "block" toggle is what’s forcing a sync layout, so I’d dodge it entirely and keep the tip “measurable” all the time (positioned + hidden), then do one rAF pass that does reads → math → writes.

let raf = 0;

function positionTip(btn, tip) {
  cancelAnimationFrame(raf);

  raf = requestAnimationFrame(() => {
    // keep it measurable without affecting layout
    tip.style.position = "fixed";
    tip.style.left = "0px";
    tip.style.top = "0px";
    tip.style.visibility = "hidden";
    tip.style.display = "block";

    // READS (batch)
    const b = btn.getBoundingClientRect();
    const t = tip.getBoundingClientRect();
    const vw = document.documentElement.clientWidth;
    const vh = document.documentElement.clientHeight;

    // COMPUTE
    const pad = 8;
    let x = b.left;
    let y = b.bottom + pad;

    x = Math.max(pad, Math.min(x, vw - t.width - pad));

    // flip up if needed
    if (y + t.height + pad > vh) y = b.top - t.height - pad;

    // WRITES (batch)
    tip.style.transform = `translate3d(${Math.round(x)}px, ${Math.round(y)}px, 0)`;
    tip.style.visibility = "visible";
  });
}

window.addEventListener("resize", () => positionTip(btn, tip));

Using fixed + transform keeps it from pushing anything around, and the rAF debounce keeps resize from spamming layout work. If you want it even smoother on resize, you can call positionTip from a ResizeObserver on the button instead of the whole window.

1 Like

One tiny tweak to that approach: you can avoid the display = "block" write entirely by keeping the tooltip in the DOM with display:block from the start and hiding it via opacity:0; pointer-events:none; (or visibility:hidden) plus position:fixed; left:0; top:0; so it’s always measurable without triggering a display-state flip.

Kirupa has a good walkthrough on what causes layout thrashing + how to batch DOM reads/writes (and why getBoundingClientRect() after style writes forces sync layout): https://www.kirupa.com/html5/avoiding_layout_thrashing.htm

1 Like