scrollWidth Does Not Detect Clipping: 240 False Positives on One Page
📅 September 13, 2026 · ⏱️ 11 min read · ♻️ Updated
You have a box. You want to know whether it is cutting off what is inside it — not whether it could, not whether it has a scrollbar, but whether a visitor is being shown less than you wrote.
Search for how to do that and you get one answer, everywhere, in every framework:
// the advice you will find everywhere
const all = [...document.querySelectorAll('*')];
const clipped = all.filter(
(el) =>
el.scrollWidth > el.clientWidth ||
el.scrollHeight > el.clientHeight,
);
console.log(clipped.length);
I ran exactly that over every element of one ordinary page of mine — the front page of dankchess.com, in headless Chromium at a 390px viewport, device pixel ratio 1. The page has 1616 elements. The probe flagged 240 of them.
Then I looked at the 240. Not one was cutting off anything. Every single hit was a false positive, and they were false in three different ways, which is why no single tweak to the expression rescues it.
scrollWidth vs clientWidth vs offsetWidth
The three properties all report a width in CSS pixels, and they measure three different boxes. Each column says whether that part of the box counts toward the number; “Overflow” means content that spills past the padding box:
| Property | Box | Border | Padding | Scrollbar | Overflow |
|---|---|---|---|---|---|
offsetWidth | border box | yes | yes | if rendered | no |
clientWidth | padding box | no | yes | subtracted | no |
scrollWidth | scrolling area | no | yes | no | yes |
The scrolling area is the padding box grown to take in the scrollable overflow of the element’s descendants, which is why it is the only one of the three that can be wider than the element itself.
Rounding is the same story for all three: the CSSOM View specification declares each one as a long, an integer, and does not say how a fractional width becomes one; MDN’s reference pages say only “An integer”. If you need the fraction, getBoundingClientRect() returns it. The rows come from the getter algorithms in the CSSOM View editor’s draft (drafts.csswg.org/cssom-view) and MDN’s pages for each property, both read on 2026-10-10.
To see all three disagree on one box, I added this element to the same dankchess.com page, in the same headless Chromium at the same 390px viewport and device pixel ratio 1, and read it on 2026-10-10:
.box {
box-sizing: content-box;
width: 200.5px;
height: 60px;
padding: 10px;
border: 5px solid;
overflow: auto;
}
.box > div { width: 400px; height: 120px; }
offsetWidthread 231. The border box is 200.5 + 2 × 10 + 2 × 5 = 230.5px, andgetBoundingClientRect().widthread exactly 230.5.clientWidthread 221. The padding box is 200.5 + 2 × 10 = 220.5px.scrollWidthread 420: the 400px child plus 10px of padding on each side.
In this run both half-pixel widths rounded up. This Chromium draws overlay scrollbars that take no layout width, so the box does not exercise the scrollbar column of the table; a scrollbar that takes up space would come out of clientWidth and stay inside offsetWidth.
That box reads scrollWidth 420 against clientWidth 221 and hides nothing a reader cannot scroll to, which is the whole problem with treating scrollWidth > clientWidth as a clipping test.
What the reading actually means
clientWidth is the width of the padding box, integer-rounded. scrollWidth is the width of the scrollable overflow area — the union of the padding box with the scrollable overflow of every descendant — also integer-rounded, rounded up. So scrollWidth > clientWidth means one thing and one thing only: the overflow area is bigger than the box.
That is a statement about geometry. Clipping is a statement about overflow. The two are related the way “this door is narrower than that sofa” is related to “the sofa is stuck”: relevant, not sufficient, and the gap is where the false positives live.
Splitting the 240 hits by the computed overflow of the element that produced them:
- 227 had
overflow: visibleon both axes. A visible box does not clip. Its content paints outside the border box, in full, and the reader sees all of it. That is almost every hit, and every one of them is noise. - 2 had
overflow-x: autoorscroll. Those are scrollers I wrote on purpose. - 11 had
overflow: hiddenon at least one axis — the only ones that could be cutting something. None of them was.
The three ways the reading lies
Fractional layout, rounded twice. The chessboard on that page is a display: grid with 64 square children. Its border box measures 288.3125px wide. clientWidth reports 288. scrollWidth reports 289. Nothing overflows it — the last square’s right edge sits exactly on the container’s right edge — but one value was rounded down and the other rounded up, and the naive probe reads the 1px gap as a defect. This is the single most common false positive in a layout built from 1fr, % or vw, because those produce fractional track sizes by design and integer DOM properties have to land somewhere.
Elements that scroll on purpose. The same page has a row of chips under the hero with overflow-x: auto. It reads scrollWidth 1303 against clientWidth 308 — a 995px overflow, the largest on the page by a wide margin, and completely intended. Any probe that sorts its hits by overflow size puts your deliberate scrollers at the top of the list forever.
Visually-hidden patterns. The skip link at the top of the document is the classic 1px-box clip: overflow: hidden with a 1px by 1px box. It reads 54 against a clientWidth of 1, and 83 against a clientHeight of 1. It is hidden on purpose, it is hidden correctly, it is the accessibility feature working, and it looks — to scrollWidth — exactly like a catastrophically clipped element. Same for clip-path insets and any text-indent: -9999px survivor.
A probe that answers the real question
The question is not “is the overflow area bigger”. It is “does a child’s edge fall outside the box, on an axis where the box is clipping”. Both halves matter, and getBoundingClientRect gives you the first half in fractional pixels instead of rounded integers:
// clip-probe.js — paste into devtools
function clipOverflow(el) {
const last = el.lastElementChild;
if (!last) return null;
const cs = getComputedStyle(el);
const box = el.getBoundingClientRect();
const child = last.getBoundingClientRect();
const overX = child.right - box.left - el.clientWidth;
const overY = child.bottom - box.top - el.clientHeight;
const cutX = cs.overflowX === 'hidden' && overX > 1;
const cutY = cs.overflowY === 'hidden' && overY > 1;
if (!cutX && !cutY) return null;
return { el, overX, overY };
}
const cut = [...document.querySelectorAll('*')]
.map(clipOverflow)
.filter(Boolean);
console.table(cut);
Three deliberate choices in there. overflowX === 'hidden' is the whole point — a box that is not clipping cannot clip. The > 1 tolerance eats the sub-pixel rounding, and one CSS pixel of slack is worth the trade because nothing legible is lost in one pixel. And child.right - box.left measures the child against the container’s content origin, so the comparison is against clientWidth and not against the border box, which is what makes it agree with itself when there is padding.
On the same page, at the same viewport, this returns 0.
That number is only interesting if the probe can still say yes. I built four control boxes in the same page and ran it over them: a 100px-wide overflow: hidden box holding a 180px child (flagged, 80px over), a 40px-tall overflow: hidden box holding a 90px child (flagged, 50px over), a box whose child fits (silent), and an overflow-x: auto box with the same 180px child (silent, correctly — it scrolls). So the 0 is a real 0, not a probe that never fires.
What it still cannot see
The probe cannot see a middle child that overflows while the last child does not, text truncated by -webkit-line-clamp or text-overflow: ellipsis, a line box taller than its container, or anything clipped by clip-path or by an ancestor further up. Taking those one at a time, because I would rather publish the holes than have you find them:
- Only the last child is measured. A middle child that pokes out while the last one fits is invisible to this. Widening it to
el.childrenis a one-line change that costs a rect read per child instead of one per element; on a 1616-element page that is still milliseconds, and it is what I would run in CI rather than in a console. - Text truncation is not element overflow.
-webkit-line-clampandtext-overflow: ellipsiscut characters without any element crossing any boundary. Different measurement entirely — you have to compare aRangerect or a cloned unclamped node. - Line boxes are not elements. The same chessboard reads
scrollHeight301 againstclientHeight288. That 13px comes from the piece glyphs: atfont-size: 36.66pxeach piece’s line box is 49px tall inside a 36px square, so the scrollable overflow area grows even though the squares themselves fit perfectly. My probe misses it, and in this case that is the right answer — the ink is far smaller than the line box and the board renders whole — but it is luck, not skill. - Ancestors clip too. An element can be fully inside its own parent and still be cut off by a clipping grandparent, or by
clip-path, or by atransformthat moved it.
It narrows the question from 240 to a handful you can actually look at. That is the entire claim.
The grid case, worked
The board is worth finishing because it is where a real bug hid behind the same numbers.
A chessboard on one of my own sites is a grid of 64 squares inside a frame. The obvious CSS is grid-template-columns: repeat(8, 1fr). What almost nobody has internalised is that 1fr is shorthand for minmax(auto, 1fr), and that auto minimum refuses to shrink a track below its content’s min-content contribution. The pieces are text. Measured directly — clone one square, put it in a width: min-content box, read the rect — a square’s min-content contribution is 37px.
I re-measured all of this by cloning the live board into a host 288.3125px wide — the width the real board gets at a 390px viewport — and swapping only the track definition between readings:
repeat(8, 1fr)— computed tracks 37px each, 8 of them, 296px of columns in a box whoseclientWidthis 288.scrollWidth296. That one is real, and the probe above agrees: it puts the last square’s right edge 8px past the content box, on an axis where the board setsoverflow: hidden. The rightmost file was genuinely being cut off.repeat(8, minmax(auto, 1fr))— byte-for-byte the same reading, 37px and 296px, which is the proof that the two are the same declaration.repeat(8, minmax(0, 1fr))— the floor is gone and the tracks fit: 36.0312px and 36.0469px, alternating, summing to 288.3124px against a box of 288.3125px. Chromium is not giving eight identical tracks; it is dealing out the leftover sixteenths so the row lands on the box.
/* 1fr keeps a min-content floor and can overflow. */
.board { grid-template-columns: repeat(8, 1fr); }
/* minmax(0, 1fr) removes the floor. */
.board { grid-template-columns: repeat(8, minmax(0, 1fr)); }
And here is the part that matters for this whole post: after that fix, the board still reads scrollWidth 289 against clientWidth 288. The naive probe cannot tell the 296-against-288 version from the 289-against-288 version except by the size of the gap, and “off by more than one pixel” is a heuristic, not an instrument. The probe above returns null for the fixed board — it measures the last square’s edge at 0.3125px past the content box, under its own tolerance and under one pixel of ink — and a live overflow for the broken one. That is the distinction you were trying to draw in the first place.
If you want the rounding to stop entirely, newer engines let you floor the container instead: repeat(8, calc(round(down, 100%, 1px) / 8)) produces exactly 36px tracks and 288px of columns. That is what the board ships today, and it still reads scrollWidth 289 — because the line boxes of the glyphs, not the columns, are what is left in the overflow area. Rounding is not one bug. It is a queue of them.
How to check this yourself
Open any page you own, paste clip-probe.js above into the console, then paste the naive filter. Compare the two counts. My reading on 2026-09-13 was 240 against 0 over 1616 elements at a 390px viewport; yours will differ, and the ratio is the interesting part, not my number.
Then take the naive probe’s hits and group them by getComputedStyle(el).overflowX. If the visible bucket dominates the way mine did — 227 of 240 — you have just watched a measurement tell you about arithmetic while you were asking about your readers.
Follow new posts by RSS: paste dankdev.com/feed.xml into any feed reader. It is a plain XML file — no signup, no email address, no account. Posts land there the same day they go up, and there is nothing to unsubscribe from later.