Geometry, scrolling and visibility

Measure elements correctly, understand which size property you want, scroll deliberately, and observe visibility with the observer APIs.

Measuring an element

const el = document.querySelector('.card');
const rect = el.getBoundingClientRect();

rect.top;      // distance from the viewport top - not the page
rect.left;
rect.width;    // the rendered size, including padding and border
rect.height;
rect.bottom;   // top + height
rect.x; rect.y;

// sizes by definition
el.offsetWidth;    // border box: content + padding + border
el.clientWidth;    // padding box: content + padding, excludes border
el.scrollWidth;    // the width of the content, even if it overflows

// positions relative to the offset parent
el.offsetTop;
el.offsetLeft;
el.offsetParent;   // the nearest positioned ancestor

// vertical overflow
el.scrollTop;      // how far it is scrolled
el.scrollHeight;   // total content height
el.clientHeight;   // visible height

// a page-absolute position from a viewport-relative rect
const pageTop = rect.top + window.scrollY;
PropertyBoxIncludes
offsetWidthBorder boxPadding, border, scrollbar
clientWidthPadding boxPadding, excludes border and scrollbar
scrollWidthContentEverything, including overflow
rect.widthBorder boxFractional pixels, affected by transforms
getComputedStyle().widthContent boxAs declared, or the used value
  • getBoundingClientRect() returns viewport coordinates, so it changes when the page scrolls. Cache it, or add window.scrollY.
  • offsetWidth is rounded to an integer; the rect is fractional. Use the rect when precision matters.
  • A CSS transform affects the rect but not offsetWidth, which is a common source of mismatched measurements.

Scrolling on purpose

// scroll the document
window.scrollTo({ top: 0, behavior: 'smooth' });
window.scrollBy({ left: 200, behavior: 'instant' });
window.scrollY;                       // current vertical offset

// bring an element into view
el.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'nearest' });

// scroll a container, not the page
const list = document.querySelector('.results');
list.scrollTop = list.scrollHeight;                 // to the bottom
list.scrollTo({ top: 0, behavior: 'smooth' });

// respect the user's motion preference
const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
el.scrollIntoView({ behavior: reduce ? 'auto' : 'smooth', block: 'start' });

// after an async render, scroll after layout settles
requestAnimationFrame(() => {
  el.scrollIntoView({ block: 'nearest' });
});
💡
block: "nearest" scrolls the minimum distance required, and does nothing if the element is already fully visible. For focus management and error messages that is almost always what you want; "center" moves the page even when it did not need to.

IntersectionObserver and ResizeObserver

const io = new IntersectionObserver((entries) => {
  for (const entry of entries) {
    if (entry.isIntersecting) {
      loadImage(entry.target);
      io.unobserve(entry.target);       // one-shot: stop watching
    }
  }
}, {
  root: null,                 // the viewport
  rootMargin: '200px 0px',    // start loading before it is visible
  threshold: 0.25,            // 25% of the element must be visible
});

document.querySelectorAll('img[data-src]').forEach((img) => io.observe(img));
// react to size changes without a resize listener
const ro = new ResizeObserver((entries) => {
  for (const entry of entries) {
    const width = entry.contentRect.width;
    entry.target.dataset.size = width < 400 ? 'narrow' : 'wide';
  }
});
ro.observe(document.querySelector('.panel'));
  • Observers run off the main thread's layout work and batch their callbacks, which is why they replace scroll and resize handlers for visibility and sizing.
  • A high threshold on a very tall element may never be reached; use a small threshold plus rootMargin instead.
  • Call unobserve or disconnect when done. An observer holds a reference to its targets.
  • Use a scroll or resize listener only when you need the raw event; otherwise an observer is cheaper, smoother and easier to reason about.
// distinguishing "never appeared" from "scrolled past"
const sentinel = document.querySelector('#top');
new IntersectionObserver(([entry]) => {
  toolbar.classList.toggle('is-stuck', !entry.isIntersecting);
}, { threshold: 0 }).observe(sentinel);

FAQ

Why is getBoundingClientRect returning zeros?
The element is not rendered: it is inside display: none subtree, or it was removed. The rect of a non-rendered element is all zeros with no error. Make it visible, or measure a different element.
Should I use scrollIntoView or scrollTop?
Use scrollIntoView for 'bring this into view' - it handles nested scroll containers and the reduced-motion case. Use scrollTop or scrollTo when you want precise control over a specific container's position.

Performance and memory in DOM code Accessibility for DOM scripting

Last refreshed 2026-09-18.