Sizes, scrolling & coordinates
Measure element boxes, read viewport coordinates, control scrolling, handle scroll events safely, and find what is under a pointer.
- 01Measure real boxesPick the offset, client, or scroll size that fits the job.
- 02Change positionsTurn a position in the viewport into a position on the whole page.
- 03Scroll smoothlyScroll with code, react to scrolling without slowing the page, and find what is under a point.
How big is a box, and where is it?
Web pages often ask questions about real space on the screen:
- How wide is this card?
- How far has this list scrolled?
- Where is this button on the screen?
- Which element is under the mouse pointer right now?
The browser already knows the answers. Before it draws a page, it works out the size and place of every element. This work is called layout. In this lesson you learn to read the browser's answers instead of guessing them.
The answers come in two groups:
- Sizes tell you how big an element's box is. The box has layers: the content, the padding around the content, and the border around the padding.
- Coordinates tell you where a box or a point is. A coordinate is a distance in pixels from a starting edge, such as the top of the screen.
Many layout bugs come from mixing up these two groups.
Size properties answer "How big is this element?" Coordinate methods answer "Where is this element, or this point?" Scrolling links the two: it changes which part of a long page you can see.
Imagine a picture hanging on a wall. Measure the whole framed picture, and you include the wooden frame. Measure inside the frame, and you get the mat and the picture, but not the frame. Now imagine the painting is very long, and most of it is rolled up behind the frame. The part you can see is smaller than the whole painting.
- In real life: The picture
- In JavaScript: the content
- In real life: The paper mat around the picture
- In JavaScript: the padding
- In real life: The wooden frame
- In JavaScript: the border
- In real life: The empty wall around the frame
- In JavaScript: the margin, which these sizes never count
- In real life: The whole framed picture
- In JavaScript:
offsetWidthandoffsetHeight - In real life: Everything inside the wooden frame
- In JavaScript:
clientWidthandclientHeight - In real life: A long painting rolled up behind the frame
- In JavaScript:
scrollWidthandscrollHeight
In the previous lesson, Styles & classes, you changed how elements look. Now you read the sizes and places that those styles produce. The Event loop lesson showed that the browser draws the page in steps. The same order helps here: change styles first, let the browser do layout, then read all your measurements together.
Three sizes: offset, client, and scroll
INTERACTIVEEvery element has three groups of size properties. The first word of each name tells you which part of the box it measures:
offsetWidthandoffsetHeightmeasure the whole box: content, padding, and border. If the box has a scrollbar, they count it too. They are always whole numbers, and they ignore transforms. A transform is a CSS effect, such astransform: scale(1.5), that makes an element look bigger or smaller without changing the layout.clientWidthandclientHeightmeasure inside the border: content and padding. They leave out the border and any scrollbar.clientTopandclientLeftare usually the widths of the top and left borders.scrollWidthandscrollHeightmeasure all of the content, including the parts that stick out and are hidden.scrollTopandscrollLefttell you how far the box has scrolled. You can also set them to scroll the box.
Try the playground below slowly:
- Turn on the padding. Both the offset sizes and the client sizes grow.
- Turn on the border. The offset sizes grow again, but the client sizes do not.
- Turn on the overflow.
scrollHeightgrows, because the hidden content still counts.
const card = { offsetWidth: 220, clientWidth: 204, scrollWidth: 260 };
console.log(card.offsetWidth - card.clientWidth);
console.log(card.scrollWidth - card.clientWidth);This small example uses numbers that were already measured, so it needs no real element.
- Line 2 takes the inside width away from the whole width: 220 − 204 prints
16. Those 16 pixels are the border, plus a scrollbar if there is one. - Line 3 takes the inside width away from the width of all the content: 260 − 204 prints
56. Those 56 pixels of content stick out of view.
offsetWidth counts the border; clientWidth stops inside it. scrollWidth also counts the content that sticks out of view.const hiddenBelow = element.scrollHeight - element.scrollTop - element.clientHeight;const atBottom = hiddenBelow <= 1;scrollTop + clientHeight ≈ scrollHeight0 / 0Content
- 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 1 pixel). scrollTop + clientHeight has reached scrollHeight.
Many pages need to know if a scrolling box has reached the bottom. For example, a chat app loads older messages, or a "Back to top" button appears. This code checks it:
const hiddenBelow = element.scrollHeight - element.scrollTop - element.clientHeight;const atBottom = hiddenBelow <= 1;- Line 1 works out
hiddenBelow: the number of pixels still hidden below the part you can see. - Line 2 says the box is "at the bottom" when that number is 1 or less.
Why not check for exactly 0? Sizes on the screen can include parts of a pixel, such as 99.5. Some properties round them to whole numbers. So good code asks "Is it within one or two pixels?" instead of "Is it exactly equal?"
A scroll position has limits. It can go from 0 up to scrollHeight - clientHeight. If you set a bigger number, the browser uses the biggest allowed value instead. If you set a negative number, the browser uses 0. So after you set a scroll position, read it again to see where the box really is.
const maxScroll = 300 - 60;
console.log(Math.min(500, maxScroll));
console.log(Math.max(-20, 0));This example copies what the browser does. Math.min picks the smaller of two numbers, and Math.max picks the bigger one.
- Line 1: the box has 300 pixels of content and shows 60 of them, so the biggest scroll position is 240.
- Line 2 asks for 500 and gets
240. - Line 3 asks for -20 and gets
0.
Step through the bottom check
STEP THROUGHNow step through the math one line at a time. The box has these measurements:
scrollHeightis 300: the height of all the content.clientHeightis 100: the height you can see.scrollTopis 199: the box has scrolled 199 pixels down.
So 1 pixel is still hidden. Does that count as "at the bottom"? The tolerance is how many pixels you allow. Pick one, guess the answer, then step through.
hiddenBelow is 300 − 199 − 100 = 1.Guess whether the box is at the bottom. Change the tolerance, then step through the math.
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 look alike on purpose.
Where an element is: getBoundingClientRect
LIVE READOUTThe viewport is the part of the page you can see in the browser window right now.
element.getBoundingClientRect() tells you where an element is in the viewport. It returns an object called a DOMRect, which holds these numbers:
topandleft: how far the element's top and left edges are from the top and left of the viewport.bottomandright: how far the element's bottom and right edges are from the top and left of the viewport.widthandheight: the size of the element's whole box, with content, padding, and border.
Because rect.top is measured from the viewport, it changes when you scroll the page.
Look at a road sign through the windshield of a car. As you drive, the sign moves across the glass. But the sign's place on the map stays the same.
getBoundingClientRect() gives the place in the glass. To get the place on the map, add scrollX and scrollY: how far the page has scrolled sideways and down.
- In real life: What you see through the windshield
- In JavaScript: the viewport
- In real life: Where a road sign appears in the glass
- In JavaScript:
rect.topandrect.left - In real life: Driving forward
- In JavaScript: scrolling the page
- In real life: The sign’s place on a paper map
- In JavaScript:
rect.top + scrollY, the place on the whole page
rect.top is measured from the top of the viewport. Add scrollY, the part of the page scrolled past, to measure from the top of the whole page.The CSS width you write is not always the width you measure. It depends on the CSS box-sizing property:
- With
content-box(the default),widthsets only the content. The padding and border are added outside it. - With
border-box,widthsets the whole box. The padding and border fit inside it.
const sizes = { cssWidth: 200, padding: 20, border: 4 };
const contentBox = sizes.cssWidth + sizes.padding * 2 + sizes.border * 2;
const borderBox = sizes.cssWidth;
const contentWidth = sizes.cssWidth - sizes.padding * 2 - sizes.border * 2;
console.log(contentBox, borderBox, contentWidth);The example has a CSS width of 200, 20 pixels of padding on each side, and a 4-pixel border on each side.
- Line 2: with
content-box, the outer width is 200 + 40 + 8 = 248. - Line 3: with
border-box, the outer width stays 200. - Line 4: then the content gets only 200 − 40 − 8 = 152.
So the program prints 248 200 152. When CSS rules are involved, measure the real element. Don't work out its width from one line of CSS.
The rectangle can include parts of a pixel, and it includes transforms. Turn on the scale in the playground below. The width from getBoundingClientRect() changes with the scale, but offsetWidth keeps 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 rect.top change. pageTop stays the same, because it adds scrollY. rect.width is 0, and offsetWidth is 0.
There are two ways to measure a position. Viewport coordinates are measured from the top-left corner of what you can see. Document coordinates are measured from the top-left corner of the whole page. Mouse events give you both: event.clientX and event.clientY are viewport coordinates, and event.pageX and event.pageY are document coordinates.
The table below compares the two. Each row asks a question, then gives the answer for each kind of position.
| Question | Viewport (what you can see) | Document (the whole page) |
|---|---|---|
| Where is it on my screen? | Use rect.top, rect.left, clientX, or clientY. | This is the wrong kind for this question. |
| Where is it on the whole page? | Add the scroll amount first. | Use rect.top + scrollY, rect.left + scrollX, pageX, or pageY. |
| Does it change when you scroll? | Yes. The numbers change as the page moves. | Usually not. It changes only if the layout changes. |
| What is it good for? | Finding what is under the pointer, sticky parts, and things drawn over what you see. | Saving positions, placing things on the page, and scrolling to a spot. |
event.clientXrect.leftdocument.elementFromPoint(clientX, clientY)event.pageYrect.top + scrollY- place a pop-up on the page
Put each value with the kind of position it measures.
Scrolling with code
INTERACTIVETwo methods scroll a box or the page:
scrollTomoves to an exact position, such as the very top.scrollBymoves by an amount from where you are now, such as 80 pixels down.
Both work on elements and on window, the whole page. For example, window.scrollTo({ top: 0, behavior: "smooth" }) moves the page to the top. The option behavior: "smooth" makes it slide instead of jump. Only scroll the page when the reader asks, for example after they click a button. Moving the page by surprise is confusing.
element.scrollIntoView() is often easier. It asks the browser to scroll until that element can be seen. The option block: "nearest" means "move as little as needed." That works well when a scrolling box sits inside a scrolling page.
list.scrollBy({ top: 80, behavior: "smooth" });item.scrollIntoView({ behavior: "smooth", block: "nearest" });Try the buttons. They scroll only this list, not the whole page.
window for the whole page. Use them only after the reader clicks or asks.window.innerWidth includes the browser's scrollbar. document.documentElement.clientWidth leaves it out. (document.documentElement is the <html> element.) Use the second one when you want the width that the page can really use.
const innerWidth = 1024;
const clientWidth = 1007;
console.log(innerWidth - clientWidth);In this example, the difference is 17 pixels. Some computers draw the scrollbar on top of the page instead of beside it. Then the difference is smaller, or 0. So measure it; don't assume 17.
Reacting to scrolling
ONCE PER FRAMEWhen the reader scrolls, the browser fires a scroll event, often many times a second. Heavy work in every event can make the page stutter.
There is one problem to watch for. Suppose each event changes a style, then reads a size, then changes a style, then reads again. Each read after a change makes the browser redo its layout at once. Doing this again and again is called layout thrashing. To avoid it:
- Do all your reads together.
- Then do all your writes together.
- Use
requestAnimationFramefor changes on the screen. It runs your function once, just before the browser draws the next frame (the next picture of the page).
To give a correct answer, a read such as offsetWidth or getBoundingClientRect() can make the browser finish any style changes first. One read is fine. The problem is switching between writes and reads many times in a loop.
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
- scroll events
- 0
- screen updates
- 0
- scrollend events
- 0
One scroll can fire many scroll events. The screen-update counter goes up at most once per frame, so the screen work runs far less often.
{ passive: true } when the listener never calls preventDefault(). To track when things come into view, use IntersectionObserver; the later Observers lesson covers it.Add { passive: true } to a scroll listener when it never calls preventDefault(). This tells the browser that your listener won't stop the scroll, so the browser can scroll without waiting for it.
To find out when a card comes into view, use IntersectionObserver instead. The later Observers lesson covers it. Newer browsers also have a scrollend event, which fires once when scrolling stops. Before you use it, check that the browser has it with "onscrollend" in document.
What is under a point: elementFromPoint
HIT TESTdocument.elementFromPoint(x, y) asks the browser: "Which element is on top at this point of the viewport?" It uses viewport coordinates, the same kind as clientX and clientY from mouse events. If the point is outside the viewport, it returns null.
Put a finger on a phone screen covered in stickers and ask: "What is right under my finger?" You get the sticker on top, not the ones under it.
- In real life: Your fingertip
- In JavaScript: the
x, ypoint in the viewport - In real life: The top sticker under your finger
- In JavaScript: the element it returns
- In real life: A finger off the edge of the screen
- In JavaScript:
null
Finding the element under a point is called hit testing. Drag-and-drop tools and page inspectors use it.
area.addEventListener("pointermove", (event) => { const hit = document.elementFromPoint(event.clientX, event.clientY);});clientX and clientY.Move the pointer inside the area.
elementFromPoint returns the element on top at a point in the viewport, or null if the point is outside the viewport.Where you’ll use this
You will meet these measurements in everyday pages:
- A tooltip (a small pop-up label) reads its button's rectangle and places itself next to the button.
- A chat app checks if the message list is near the bottom before it scrolls to a new message.
- A very long list draws only the rows you can see. It uses
clientHeightandscrollTopto pick them. - A drawing tool uses
elementFromPointto find the shape 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)`;- Line 1 reads the button's rectangle in the viewport.
- Lines 2 and 3 add the scroll amounts to get positions on the whole page.
- Line 5 moves the tooltip there with one style change.
This code needs real elements, so it doesn't run here. The next example does the same math with numbers.
const button = { bottom: 120, left: 40 };
const page = { scrollX: 10, scrollY: 300 };
console.log(button.left + page.scrollX, button.bottom + page.scrollY);- The button's bottom is 120 pixels below the top of the viewport, and the page has scrolled 300 pixels down. So on the whole page, the tooltip's top is 120 + 300 = 420.
- The button's left edge is 40 pixels from the left of the viewport, and the page has scrolled 10 pixels sideways. So on the whole page, its left is 40 + 10 = 50.
Line 3 prints 50 420.
const card = { top: -20, right: 300, bottom: 80, left: 40 };
const viewport = { width: 800, height: 600 };
const inView = card.bottom > 0 && card.right > 0 && card.top < viewport.height && card.left < viewport.width;
console.log(inView);Line 3 checks four things. The card is in view only if all four are true:
- Its bottom is below the top edge of the viewport:
card.bottom > 0. - Its right edge is past the left edge of the viewport:
card.right > 0. - Its top is above the bottom edge of the viewport:
card.top < viewport.height. - Its left edge is before the right edge of the viewport:
card.left < viewport.width.
This card starts 20 pixels above the viewport, but its bottom is 80 pixels below the top edge. So part of it can still be seen, and the program prints true. If you checked only the top, you would hide a card that is still partly on the screen.
Notice the order in the tooltip code: read the rectangle, work out the page position, then make one style change. If you need several measurements, read them all first. Then change the classes or styles. That stops the browser from redoing layout again and again.
Misconceptions and edge cases
- "offsetWidth and getBoundingClientRect().width are the same." Not always.
offsetWidthis a whole number and ignores transforms.getBoundingClientRect()can include parts of a pixel, and it includes transforms. - "clientWidth is the content width." It also includes the padding. It leaves out the border and any scrollbar.
- "rect.top is a fixed place on the page." It is measured from the viewport, so it changes when you scroll. Add
scrollYto get the place on the whole page. - "Reading sizes costs nothing." A read right after a style change can make the browser redo layout at once. Do the reads together, then the writes together.
- "Hidden elements still have a size." An element with
display: nonetakes up no space. Its sizes are 0, and itsoffsetParentisnull.offsetParentis the parent element thatoffsetTopandoffsetLeftare measured from. - "elementFromPoint uses pageX and pageY." It uses viewport coordinates. Pass it
clientXandclientY.
Practice exercises
5 EXERCISESDo the math 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, and 0 <= 1 is true. The box is exactly at the bottom.
A target is 42 pixels below the top of the viewport. The page has scrolled 300 pixels down. How far is the target from the top of the whole page?
const rect = { top: 42, left: 10 };
const scrollY = 300;
console.log(rect.top + scrollY);The top on the page 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);Here the scrollbar takes 1024 - 1007, which is 17 pixels. The real number depends on the browser and the computer's settings.
After the page scrolls, the popup appears too high. Fix the numbers so it sits under the button.
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 turns viewport numbers into page numbers. Now the popup stays next to the button after the page scrolls.
A sticky header must update while the page scrolls. Write a listener that doesn't do heavy work in every scroll 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 scroll event only books the update. ticking makes sure only one update is booked per frame. requestAnimationFrame runs it just before the browser draws, and { passive: true } tells the browser the listener never stops the scroll.
Check your understanding
8 QUESTIONSQuestion 1 of 8Which size includes the width of the border?
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()measures positions from where?Choose an answer to see the explanation.
Question 4 of 8This prints the target's top on the whole page. What is it?
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 scroll until an element can be seen?
Choose an answer to see the explanation.
Question 6 of 8What is the best way to do heavy screen updates while the page scrolls?
Choose an answer to see the explanation.
Question 7 of 8What kind of point does
elementFromPoint(x, y)expect?Choose an answer to see the explanation.
Question 8 of 8Which sentence about transforms is true?
Choose an answer to see the explanation.
Key takeaways
offsetWidthandoffsetHeightmeasure the whole box with its border, as whole numbers. They ignore transforms.clientWidthandclientHeightmeasure inside the border.scrollWidthandscrollHeightmeasure all the content, even the hidden part.getBoundingClientRect()tells you where the box is in the viewport. It can include parts of a pixel, and it includes transforms.- A position on the whole page = a position in the viewport +
scrollXorscrollY. - Do layout reads together and writes together. Update the screen once per frame with
requestAnimationFrame. elementFromPointtakes viewport coordinates and returns the element on top at that point.
Sizes tell you how big a box is. Coordinates tell you where a box or a point is, measured from the viewport or from the whole page.
Up next: Selection & Range.