How to check if text is truncated
Compare scrollWidth with clientWidth on the element, and scrollHeight with clientHeight for anything that wraps. On the fixture below a date box reported 282 against 188 and was hiding 4 March 2027, while its textContent still held all 36 characters and the accessibility tree still carried the whole string.
Why check this
Clipped text is the failure mode of a localized build, and it is the one that leaves no trace. overflow: hidden with no ellipsis renders a shorter string that looks intended. Run this after a translation import, after a font change and after any width change in a shared component.
The failure it catches is a lost fact rather than a lost pixel. A row reading Subscription renews on 14 M tells a customer nothing about when they will be charged, and every DOM assertion on that row still passes, because the text is in the node.
Prerequisites
- Chrome or another Chromium browser. The comparison runs in the page, so any Chromium build works.
- Node 22 to serve the fixture. The figures below are one capture, in Chrome 152 on one Windows machine resolving
"Segoe UI", system-ui, sans-serifat 16 px, on 2026-09-12. - The fixture. Save it as
truncate.html.silentclips with no ellipsis,ellipsisclips with one,wrappedandclampedlose lines instead of characters, andhairlineis resized from the Console for the sub-pixel case.
<!doctype html>
<html lang="en">
<meta charset="utf-8">
<title>Truncation fixture</title>
<style>
body{font:16px "Segoe UI",system-ui,sans-serif;margin:0;padding:16px}
.box{width:180px;border:1px solid #999;padding:4px;margin:0 0 10px}
#silent{overflow:hidden;white-space:nowrap}
#ellipsis{overflow:hidden;white-space:nowrap;text-overflow:ellipsis}
#wrapped{overflow:hidden;white-space:normal;height:42px}
#clamped{overflow:hidden;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2}
#hairline{overflow:hidden;white-space:nowrap}
</style>
<div class="box" id="silent">Subscription renews on 14 March 2027</div>
<div class="box" id="ellipsis">Subscription renews on 14 March 2027</div>
<div class="box" id="wrapped">Subscription renews on 14 March 2027 and the card ending 4242 is charged</div>
<div class="box" id="clamped">Subscription renews on 14 March 2027 and the card ending 4242 is charged</div>
<div class="box" id="hairline">Subscription renews</div>
- The server, saved next to it as
serve.mjsand started withnode serve.mjs. It listens on 127.0.0.1 only.
import { createServer } from 'node:http';
import { readFileSync } from 'node:fs';
const PORT = 8613;
createServer((req, res) => {
const name = req.url.split('?')[0].replace(/^\//, '') || 'index.html';
let body;
try {
body = readFileSync(new URL(name, import.meta.url));
} catch {
res.writeHead(404).end('not found');
return;
}
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
res.end(body);
}).listen(PORT, '127.0.0.1', () => console.log(`http://127.0.0.1:${PORT}/`));
Steps
- Step 1.
Open
http://127.0.0.1:8613/truncate.html, open DevTools, Console tab, and paste the two comparisons over all four boxes.['silent', 'ellipsis', 'wrapped', 'clamped'].map((id) => { const el = document.getElementById(id); return id.padEnd(9) + `clientW ${String(el.clientWidth).padEnd(4)} scrollW ${String(el.scrollWidth).padEnd(4)}` + ` clientH ${String(el.clientHeight).padEnd(3)} scrollH ${String(el.scrollHeight).padEnd(3)}` + ` wide ${el.scrollWidth > el.clientWidth} tall ${el.scrollHeight > el.clientHeight}` + ` chars ${el.textContent.length}`; }).join('\n');silent clientW 188 scrollW 282 clientH 29 scrollH 29 wide true tall false chars 36 ellipsis clientW 188 scrollW 282 clientH 29 scrollH 29 wide true tall false chars 36 wrapped clientW 188 scrollW 188 clientH 50 scrollH 92 wide false tall true chars 72 clamped clientW 188 scrollW 188 clientH 50 scrollH 92 wide false tall true chars 72The width comparison finds the two
nowrapboxes and reportsfalsefor both wrapping boxes, which are losing more text than either of them. The height comparison finds those. A check that tests only width passes two of the four. - Step 2.
Name the characters that were cut. Walk a Range over the text node and keep every character painted past the content edge.
['silent', 'ellipsis', 'wrapped', 'clamped'].map((id) => { const el = document.getElementById(id), node = el.firstChild; const b = el.getBoundingClientRect(), cs = getComputedStyle(el); const right = b.right - parseFloat(cs.borderRightWidth) - parseFloat(cs.paddingRight); const bottom = b.bottom - parseFloat(cs.borderBottomWidth) - parseFloat(cs.paddingBottom); let cut = ''; for (let i = 0; i < node.data.length; i++) { const rg = document.createRange(); rg.setStart(node, i); rg.setEnd(node, i + 1); const r = rg.getBoundingClientRect(); if (r.width === 0 && r.height === 0) continue; if (r.right > right + 0.01 || r.bottom > bottom + 0.01) cut += node.data[i]; } return `${id.padEnd(9)} lost ${JSON.stringify(cut)}`; }).join('\n');silent lost "4 March 2027" ellipsis lost "4 March 2027" wrapped lost "card ending 4242 is charged" clamped lost "card ending 4242 is charged"silentandellipsisreport the same loss, becausetext-overflowchanges what is painted and not where the characters are laid out. The date is gone from both. A failure message carrying this string is worth far more in a report than a pixel count. - Step 3.
Read what the clipped elements hand to assistive technology. Save this as
ax.mjs,npm i puppeteer-core, and runnode ax.mjswhile the fixture is served.import { launch } from 'puppeteer-core'; const CHROME = 'C:/Program Files/Google/Chrome/Application/chrome.exe'; const browser = await launch({ executablePath: CHROME, headless: true }); const page = (await browser.pages())[0]; await page.goto('http://127.0.0.1:8613/truncate.html', { waitUntil: 'load' }); const cdp = await page.createCDPSession(); const { nodes } = await cdp.send('Accessibility.getFullAXTree'); for (const n of nodes) { if (n.ignored || n.role?.value !== 'StaticText') continue; console.log(JSON.stringify(n.name?.value)); } console.log('chrome', await browser.version()); await browser.close();"Subscription renews on 14 March 2027" "Subscription renews on 14 March 2027" "Subscription renews on 14 March 2027 and the card ending 4242 is charged" "Subscription renews on 14 March 2027 and the card ending 4242 is charged" "Subscription renews" chrome Chrome/152.0.7977.76Every name is the full string. The clipping is a paint decision and the accessibility tree never learns about it, so the reader using a screen reader gets the renewal date and the reader looking at the screen does not. The ellipsis character is absent from the second name as well: it is painted, not inserted.
- Step 4.
Find where the width comparison stops working. Shrink the box in tenths of a pixel and watch both values.
const el = document.getElementById('hairline'); const rg = document.createRange(); rg.selectNodeContents(el); const t = rg.getBoundingClientRect().width; let out = `text ${t.toFixed(2)}px, padding 4px each side\noverflow clientWidth scrollWidth scrollWidth>clientWidth\n`; for (let over = 0.1; over <= 1.005; over += 0.1) { el.style.width = (t - over) + 'px'; out += over.toFixed(2).padStart(7) + 'px' + String(el.clientWidth).padStart(11) + String(el.scrollWidth).padStart(13) + String(el.scrollWidth > el.clientWidth).padStart(23) + '\n'; } out.trim();text 141.80px, padding 4px each side overflow clientWidth scrollWidth scrollWidth>clientWidth 0.10px 150 150 false 0.20px 150 150 false 0.30px 149 150 true 0.40px 149 150 true 0.50px 149 150 true 0.60px 149 150 true 0.70px 149 150 true 0.80px 149 150 true 0.90px 149 150 true 1.00px 149 150 trueclientWidthandscrollWidthare integers, so the first two rows overflow by real amounts and compare equal. Where the boundary falls depends on the fractional widths in play, which is why the Range measurement in step 2 is the one to trust when the answer matters.
How to read the result
| What you see | What it means | What to do |
| --- | --- | --- |
| scrollWidth above clientWidth | A single line is cut at the end | Read the lost substring with step 2 and put it in the failure message. |
| scrollHeight above clientHeight | Whole lines are gone, not characters | Check the fixed height or the line clamp on the container. |
| Both equal and the text still looks short | Overflow under 1 px, or an ancestor is clipping | Use the Range walk. Integer properties cannot see a fraction. |
| textContent.length unchanged | The DOM has the text, the screen does not | Stop asserting on text content for this case. |
| The accessibility name is the full string | The loss is visual only | Fix the layout. An ellipsis is not an accessible summary. |
Common mistakes
What to check next
- How to test text expansion in translations: the cause, measured before the text is cut.
- How to test rtl layout: mirrored containers clip at the other end, and the same comparisons apply.
- How to check accessibility tree: the tree that held the full string in step 3, and how to read the rest of it.
- How to test a page at 200 percent zoom: zoom reflows text into narrower boxes and turns near misses into cuts.
FAQ
How do I check if text is overflowing in React?
The same comparison, from a ref, after layout: el.scrollWidth > el.clientWidth. Run it in a layout effect and again on resize, because the element has no width on the first render and the check returns false for everything.
Does getBoundingClientRect detect truncation?
Not on the element. Its rect is the box, which is the size you set. The rect of a Range over the text is the size the text wants, and the difference between the two is the overflow. That is what steps 2 and 4 use.
Why does the check fail on hidden elements?
An element with display: none reports 0 for both values, so the comparison is false. Elements inside a collapsed accordion or an inactive tab give the same answer. Open the container first, or measure a clone placed off screen.
Is a title attribute an acceptable fix?
It restores the text for a mouse and not for touch or keyboard, and screen readers treat title inconsistently. Use it as a supplement. The element that holds the string still needs room for it or a visible way to get to it.
Verified
Verified by Maks VernyChrome 152.0.7977.76Node 22.23.2puppeteer-core 25.10.0
Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.
Related on this site
intermediate8 minpublished updated Maks Verny