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

<!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>
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

  1. 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 72

    The width comparison finds the two nowrap boxes and reports false for 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.

  2. 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"

    silent and ellipsis report the same loss, because text-overflow changes 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.

  3. Step 3.

    Read what the clipped elements hand to assistive technology. Save this as ax.mjs, npm i puppeteer-core, and run node ax.mjs while 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.76

    Every 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.

  4. 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                   true

    clientWidth and scrollWidth are 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

Sign: A snapshot test asserts the full string and passes while the UI shows half of it.Cause: textContent is 36 characters in both the clipped box and the intact one. Clipping happens at paint. Any assertion that reads the DOM string, and any screenshot comparison against a baseline captured with the same defect, agrees that the row is correct.
Sign: scrollWidth equals clientWidth on an element that is visibly cut.Cause: Two causes, and they need different fixes. Both properties round to integers, so an overflow of 0.10 px and 0.20 px compared equal in step 4. And an element that wraps loses lines rather than characters, so its width never grows: the wrapped box reported 188 against 188 while hiding 27 characters. Compare the heights as well, and fall back to Range geometry under a pixel.
Sign: text-overflow: ellipsis is treated as proof that nothing is lost.Cause: The ellipsis is painted over the line, and it covers text that did fit. On this fixture the glyph measures 11.73 px against a geometric loss of 94 px, so the visible string is shorter than the overflow alone implies. The ellipsis is also absent from the accessibility name, which means the two audiences get different content with no marker on either side.
Sign: The element passes and its parent is doing the clipping.Cause: overflow: hidden on an ancestor cuts a child whose own scrollWidth matches its clientWidth, because the child is laid out at full size inside a box that is not. Walk up with offsetParent and run the same comparison on each ancestor, or compare the child's bounding rect against the nearest clipping ancestor's content box.

What to check next

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.

intermediate8 minpublished updated Maks Verny