Sizes, scrolling & coordinates
Measure element boxes, read viewport coordinates, control scrolling, handle scroll events safely, and find what is under a pointer.
- 01Measure real boxesChoose offset, client, or scroll sizes for the job.
- 02Translate coordinatesTurn viewport rectangles into document positions.
- 03Scroll without jankUse scroll APIs, passive listeners, rAF throttling, and hit testing.
The map of every box
Front-end work often asks deceptively physical questions: how wide is this card, how far has this panel scrolled, where is this button on the screen, and what is under the pointer right now? The browser already knows, because it has just laid out the page. This lesson is about reading those answers without guessing.
There are two families of measurements. Box measurements describe an element’s own rectangle: its content, padding, border, scrollbar, and overflowing content. Coordinate measurements describe where a rectangle or point sits in the viewport or in the whole document. Mixing those two is the source of most layout bugs.
Size properties answer “how big is this element?” Coordinate APIs answer “where is this element or point?” Scrolling connects them by changing which part of a larger document is visible.
Imagine hanging a picture. If you measure the whole framed object, you include the wooden frame. If you measure inside the frame, you include the mat and the picture but not the frame itself. If the artwork is a long scroll rolled behind the frame, the visible part is smaller than the full canvas.
- In real life: The picture
- In JavaScript: content
- In real life: The mat around the picture
- In JavaScript: padding
- In real life: The wooden frame
- In JavaScript: border
- In real life: Space on the wall
- In JavaScript: margin: not counted by these properties
- In real life: The whole framed picture
- In JavaScript:
offsetWidthandoffsetHeight - In real life: Inside the frame
- In JavaScript:
clientWidthandclientHeight - In real life: A larger canvas rolled behind the frame
- In JavaScript:
scrollWidthandscrollHeight
Where the analogy stops: Real frames do not have browser scrollbars or fractional CSS pixels. The analogy is for remembering which layers are counted, not for every rendering detail.
The previous lesson, Styles & classes, changed how elements look. Now you will read the layout that those styles produce. You may recognize the rendering rhythm from the Event loop lesson: write styles, let the browser calculate layout, then read measurements in deliberate batches.
offset, client & scroll sizes
INTERACTIVEThe three names tell you which part of the box you are reading:
offsetWidthandoffsetHeightare the border box: content + padding + border, plus a scrollbar if it takes layout space. They are rounded integers and ignore transforms.clientWidthandclientHeightare the inside of the border: content + padding, minus scrollbar space.clientTopandclientLeftare roughly the top and left border widths.scrollWidthandscrollHeightare the full scrollable content size, including overflow that is currently out of view.scrollTopandscrollLeftare writable positions inside that scrollable content.
Try the playground slowly. Turn on padding first: both offset and client sizes grow. Turn on the border: offset grows again, but client does not. Turn on overflow: scrollHeight grows because the invisible content still exists.
const hiddenBelow = element.scrollHeight - element.scrollTop - element.clientHeight;const atBottom = hiddenBelow <= 1;scrollTop + clientHeight ≈ scrollHeight0 / 0Content box
- offsetWidth
- 0
- offsetHeight
- 0
- clientWidth
- 0
- clientHeight
- 0
- clientTop
- 0
- clientLeft
- 0
- scrollWidth
- 0
- scrollHeight
- 0
- scrollTop
- 0
The box is at the bottom within a 1px tolerance. scrollTop + clientHeight has caught up with scrollHeight.
A common pattern for infinite lists and “back to top” buttons is checking whether the inner scroll position is near the bottom:
const hiddenBelow = element.scrollHeight - element.scrollTop - element.clientHeight;const atBottom = hiddenBelow <= 1;The small tolerance matters. CSS pixels can be fractional, and some measurements are rounded. Professional code often asks “within one or two pixels?” instead of demanding perfect equality.
Scroll math without guessing
STEP THROUGHNow step through the exact arithmetic. The box has a scroll height of 300, the visible client height is 100, and it has already scrolled down 199 pixels. Is one pixel left enough to count as “at the bottom”? Change the tolerance and predict before stepping.
Predict whether the box is at the bottom. Change the tolerance, then step through the arithmetic.
script
const hiddenBelow = box.scrollHeight - box.scrollTop - box.clientHeight;const atBottom = hiddenBelow <= 2;console.log(atBottom);offsetWidthoffsetHeightclientWidthclientTopscrollWidthscrollHeightrect.topelementFromPoint(x, y)
Sort each property by what it measures. The names are similar on purpose.
getBoundingClientRect
LIVE READOUTelement.getBoundingClientRect() returns a DOMRect: top, left, right, bottom, width, and height. The rectangle is the element’s border box, and the coordinates are relative to the viewport. That means rect.top changes when the reader scrolls the page.
If you point at a road sign through the windshield, its position in the glass changes as you drive. The sign’s map location does not. getBoundingClientRect() gives the windshield position; adding scrollX and scrollY gives the map position.
- In real life: What you see through the windshield
- In JavaScript: the viewport
- In real life: A sign’s position in the windshield
- In JavaScript:
rect.topandrect.left - In real life: Driving forward
- In JavaScript: scrolling the page
- In real life: A location on the paper map
- In JavaScript:
rect.top + scrollYdocument coordinates
Where the analogy stops: The viewport can resize and the document can scroll in two directions. The analogy only separates visible-screen coordinates from whole-page coordinates.
The rectangle can contain fractional values and includes transforms. Toggle the scale below: getBoundingClientRect().width changes visually, but offsetWidth stays the layout width.
const rect = target.getBoundingClientRect();const pageTop = rect.top + window.scrollY;- rect.top
- 0
- rect.left
- 0
- rect.width
- 0
- rect.height
- 0
- pageTop
- 0
- pageLeft
- 0
- offsetWidth
- 0
Scroll the page and watch top change. Page top stays steadier because it adds scrollY. Width is 0, while offsetWidth is 0.
| Question | Viewport answer | Document answer |
|---|---|---|
| Where is it on my screen? | rect.top, rect.left, clientX, clientY | Not the right system |
| Where is it on the whole page? | Add scroll first | rect.top + scrollY, rect.left + scrollX, pageX, pageY |
| Does it change while scrolling? | Yes | Usually no, unless layout changes |
| Best for | hit testing, sticky UI, visible overlays | saving positions, document overlays, scroll targets |
event.clientXrect.leftdocument.elementFromPoint(clientX, clientY)event.pageYrect.top + scrollY- position an overlay in the document
Place each value in the coordinate system it belongs to.
Window scrolling & scrollIntoView
INTERACTIVEScrolling APIs come in pairs. scrollTo moves to an absolute position. scrollBy moves relative to the current position. Both work on elements and on window: window.scrollTo({ top: 0, behavior: "smooth" }) scrolls the page to the top after a user clicks a button. Avoid yanking the reader’s page automatically.
element.scrollIntoView() is often more convenient: it asks the browser to reveal a particular item. block: "nearest" means “move as little as necessary,” which feels better inside nested scroll containers.
list.scrollBy({ top: 80, behavior: "smooth" });item.scrollIntoView({ behavior: "smooth", block: "nearest" });Try the buttons. They only scroll the demo list, not the whole page.
window for the page, but do it in response to a clear user action.window.innerWidth includes the browser scrollbar. document.documentElement.clientWidth excludes it. Use the second value when you want the layout viewport’s usable width.
Scroll events
THROTTLEScroll events can fire rapidly. If each event reads layout, writes styles, reads again, and writes again, you can force synchronous layout repeatedly. That pattern is called layout thrashing. Batch reads together, batch writes together, and use requestAnimationFrame for visual updates.
let ticking = false;box.addEventListener("scroll", () => { if (!ticking) { requestAnimationFrame(updateReadout); ticking = true; }}, { passive: true });Scroll row 1
Scroll row 2
Scroll row 3
Scroll row 4
Scroll row 5
Scroll row 6
Scroll row 7
Scroll row 8
Scroll row 9
Scroll row 10
Scroll row 11
Scroll row 12
Scroll row 13
Scroll row 14
Scroll row 15
Scroll row 16
Scroll row 17
Scroll row 18
- raw scroll events
- 0
- rAF updates
- 0
- scrollend events
- 0
Raw scroll events can arrive many times in one gesture. The rAF counter batches visual work so it runs at most once per frame.
{ passive: true } when you do not call preventDefault(). Prefer IntersectionObserver for visibility tracking; the later Observers lesson goes deeper.Use { passive: true } for scroll listeners when you do not call preventDefault(). It tells the browser the listener will not block scrolling. For “tell me when this card becomes visible,” prefer IntersectionObserver; a later Observers lesson covers it. Modern browsers are also adding scrollend; feature-detect it with "onscrollend" in document before relying on it.
elementFromPoint
HIT TESTdocument.elementFromPoint(x, y) asks the browser which element is topmost at a point in the viewport. It uses the same coordinate system as pointer events’ clientX and clientY. If the point is outside the viewport, the result can be null.
Point at the screen and ask, “what is directly under my finger?” That is hit testing. It is useful for custom drag-and-drop, inspectors, and teaching tools.
- In real life: Your fingertip
- In JavaScript: the
x, yviewport point - In real life: The top sticker under your finger
- In JavaScript: the returned element
- In real life: A point off the glass
- In JavaScript:
null
Where the analogy stops: The browser has to consider stacking, transforms, pointer-events, and shadow DOM. The finger picture is for the basic coordinate idea.
area.addEventListener("pointermove", (event) => { const hit = document.elementFromPoint(event.clientX, event.clientY);});clientX and clientY.Move inside the target area.
elementFromPoint returns the topmost element at viewport coordinates, or null for a point outside the viewport.Where you’ll use this
Measurement code appears in everyday interfaces. A tooltip reads its target’s rect and positions itself in viewport space. A chat app checks whether the message list is near the bottom before auto-scrolling. A virtualized list uses client height and scrollTop to decide which rows to render. A drawing tool uses elementFromPoint to choose the layer under the pointer.
const rect = button.getBoundingClientRect();const top = rect.bottom + window.scrollY;const left = rect.left + window.scrollX; tooltip.style.transform = `translate(${left}px, ${top}px)`;Notice the order: read the rectangle, compute document coordinates, then write one style. If you need several measurements, read them all first; then apply classes or styles. That keeps the browser from recalculating layout over and over.
Misconceptions and edge cases
- “offsetWidth and getBoundingClientRect().width are the same.” Not always.
offsetWidthis an integer layout size and ignores transforms;getBoundingClientRect()can be fractional and includes transforms. - “clientWidth is the content width.” It includes padding and excludes borders. If a scrollbar takes space, it is subtracted.
- “rect.top is a permanent page position.” It is a viewport position. Add
scrollYfor document coordinates. - “Reading layout is free.” Reading after style writes can force synchronous layout. Batch reads and writes.
- “Hidden elements measure normally.” Elements with
display: nonemeasure as 0 and haveoffsetParentofnull. - “elementFromPoint uses pageX and pageY.” It uses viewport coordinates. Use
clientXandclientY.
Practice exercises
5 EXERCISESRun the arithmetic in your head. What does the program print?
const box = { scrollTop: 240, clientHeight: 60, scrollHeight: 300 };
const hiddenBelow = box.scrollHeight - box.scrollTop - box.clientHeight;
console.log(hiddenBelow <= 1);300 - 240 - 60 is 0, so hiddenBelow <= 1 is true. The box is exactly at the bottom.
A target is 42 pixels from the top of the viewport, and the page has scrolled 300 pixels. What is its document top?
const rect = { top: 42, left: 10 };
const scrollY = 300;
console.log(rect.top + scrollY);The document top is rect.top + scrollY, so 42 + 300 prints 342.
Use the difference between window.innerWidth and document.documentElement.clientWidth.
const windowInnerWidth = 1024;
const documentClientWidth = 1007;
console.log(windowInnerWidth - documentClientWidth);In this example the scrollbar takes 1024 - 1007, or 17 pixels. Browser and OS settings can change the real number.
The popup appears too high after the page scrolls. Fix the coordinate system.
const rect = menuButton.getBoundingClientRect();
popup.style.top = rect.bottom + "px";
popup.style.left = rect.left + "px";const rect = menuButton.getBoundingClientRect();
popup.style.top = rect.bottom + window.scrollY + "px";
popup.style.left = rect.left + window.scrollX + "px";Adding scrollY and scrollX converts the viewport rectangle into document coordinates, so the popup stays near the button after scrolling.
Write a pattern for a sticky header that updates during scrolling without doing heavy work in every raw event.
let ticking = false;
window.addEventListener("scroll", () => {
// your plan here
});let ticking = false;
window.addEventListener("scroll", () => {
if (ticking) return;
ticking = true;
requestAnimationFrame(() => {
ticking = false;
updateStickyHeader();
});
}, { passive: true });The raw event only schedules work. requestAnimationFrame batches the visual update with the browser’s next render, and the passive listener does not block scrolling.
Check your understanding
8 QUESTIONSQuestion 1 of 8Which measurement includes border width?
Choose an answer to see the explanation.
Question 2 of 8What does this print?
Read the code, then predictconst box = { scrollTop: 90, clientHeight: 100, scrollHeight: 200 }; const hiddenBelow = box.scrollHeight - box.scrollTop - box.clientHeight; console.log(hiddenBelow);Choose an answer to see the explanation.
Question 3 of 8
getBoundingClientRect()returns coordinates relative to what?Choose an answer to see the explanation.
Question 4 of 8What document top does this print?
Read the code, then predictconst rect = { top: 25 }; const scrollY = 400; console.log(rect.top + scrollY);Choose an answer to see the explanation.
Question 5 of 8Which call asks the browser to bring an element into view?
Choose an answer to see the explanation.
Question 6 of 8What is the safest pattern for expensive scroll updates?
Choose an answer to see the explanation.
Question 7 of 8
elementFromPoint(x, y)expects which coordinate system?Choose an answer to see the explanation.
Question 8 of 8Which statement about transforms is true?
Choose an answer to see the explanation.
Key takeaways
offset*reads the rounded border box and ignores transforms.client*reads the inside of the border;scroll*reads the whole scrollable content.getBoundingClientRect()returns a fractional, transformed border box relative to the viewport.- Document coordinates are viewport coordinates plus
scrollXandscrollY. - Batch layout reads and writes; throttle scroll visuals with
requestAnimationFrame. elementFromPointuses viewport coordinates and returns what is topmost there.
Sizes tell you the dimensions of boxes; coordinates tell you where those boxes and points are in the viewport or document.
Up next: Selection & Range.