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;| Property | Box | Includes |
|---|---|---|
offsetWidth | Border box | Padding, border, scrollbar |
clientWidth | Padding box | Padding, excludes border and scrollbar |
scrollWidth | Content | Everything, including overflow |
rect.width | Border box | Fractional pixels, affected by transforms |
getComputedStyle().width | Content box | As declared, or the used value |
getBoundingClientRect()returns viewport coordinates, so it changes when the page scrolls. Cache it, or addwindow.scrollY.offsetWidthis 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
thresholdon a very tall element may never be reached; use a small threshold plusrootMargininstead. - Call
unobserveordisconnectwhen 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.Related
Performance and memory in DOM code Accessibility for DOM scripting
Last refreshed 2026-09-18.