How to test text expansion in translations
Measure the rendered width of every string in the page's own font with getBoundingClientRect(), then compare scrollWidth against clientWidth on the control that holds it. On the fixture below, Notifications sat inside a 120 px button at 89.6 px in English and overflowed by 39 px as Benachrichtigungen.
Why check this
Translated strings arrive after the layout is built, so a control sized around English text is sized around the shortest version it will ever hold. Run this when the locale files land, and again whenever a fixed width, a fixed height or a nowrap appears in a component that shows copy.
The failure it catches is a label that loses its end without any sign that it did. A button with overflow: hidden and no ellipsis renders Benachrichtig and looks deliberate. Nobody files it, because in the source language the same button is correct.
Prerequisites
- Chrome or another Chromium browser. Width is a property of the font in use, so the measurement has to happen in the page, not in a spreadsheet.
- Node 22 to serve the fixture. Every number here is one capture, in Chrome 152 on one Windows machine resolving
"Segoe UI", system-ui, sans-serifat 16 px, on 2026-09-12. Another font stack gives other numbers, and the ratios move with it. - The fixture. Save it as
expand.html. The button is 120 px wide withoverflow: hiddenand no ellipsis, which is the defect. The probe span measures text without disturbing layout.
<!doctype html>
<html lang="en">
<meta charset="utf-8">
<title>Text expansion fixture</title>
<style>
body{font:16px "Segoe UI",system-ui,sans-serif;margin:0;padding:16px}
#btn{width:120px;height:36px;padding:0 8px;overflow:hidden;white-space:nowrap;
border:1px solid #333;background:#eee;font:inherit}
#probe{position:absolute;visibility:hidden;white-space:nowrap;font:inherit}
</style>
<button id="btn">Delete account</button>
<span id="probe"></span>
- 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}/`));
- German and Finnish strings to measure against. The ten pairs in step 1 are the sample used here, and a different sample gives a different mean.
Steps
- Step 1.
Open
http://127.0.0.1:8613/expand.html, open DevTools, Console tab, and paste this. It renders each string through the hidden probe and reports the width in pixels.const STRINGS = [ ['Save', 'Speichern', 'Tallenna'], ['Cancel', 'Abbrechen', 'Peruuta'], ['Search', 'Suchen', 'Hae'], ['Settings', 'Einstellungen', 'Asetukset'], ['Password', 'Passwort', 'Salasana'], ['Sign out', 'Abmelden', 'Kirjaudu ulos'], ['Delete account', 'Konto löschen', 'Poista tili'], ['Notifications', 'Benachrichtigungen', 'Ilmoitukset'], ['Upload file', 'Datei hochladen', 'Lataa tiedosto'], ['Your changes have been saved', 'Ihre Änderungen wurden gespeichert', 'Muutoksesi on tallennettu'], ]; const probe = document.getElementById('probe'); const px = (t) => { probe.textContent = t; return probe.getBoundingClientRect().width; }; const rows = STRINGS.map(([en, de, fi]) => ({ en, de, fi, w: [px(en), px(de), px(fi)] })); const mean = (f) => (rows.reduce((a, r) => a + f(r), 0) / rows.length).toFixed(2); 'en string chars en px de px de/en fi px fi/en\n' + rows.map((r) => r.en.padEnd(30) + String(r.en.length).padStart(4) + r.w[0].toFixed(1).padStart(9) + r.w[1].toFixed(1).padStart(9) + (r.w[1] / r.w[0]).toFixed(2).padStart(7) + r.w[2].toFixed(1).padStart(9) + (r.w[2] / r.w[0]).toFixed(2).padStart(7)).join('\n') + `\n\nmean de/en ${mean((r) => r.w[1] / r.w[0])} mean fi/en ${mean((r) => r.w[2] / r.w[0])}` + `\nmean character ratio de/en ${mean((r) => r.de.length / r.en.length)}` + `\nde/en high ${Math.max(...rows.map((r) => r.w[1] / r.w[0])).toFixed(2)}` + ` low ${Math.min(...rows.map((r) => r.w[1] / r.w[0])).toFixed(2)}` + `\nfont ${getComputedStyle(probe).font}`;en string chars en px de px de/en fi px fi/en Save 4 32.7 69.6 2.13 57.1 1.75 Cancel 6 46.7 76.9 1.65 54.0 1.15 Search 6 47.0 51.4 1.09 27.9 0.59 Settings 8 56.9 94.3 1.66 68.5 1.20 Password 8 66.1 62.1 0.94 60.8 0.92 Sign out 8 59.1 72.6 1.23 91.8 1.55 Delete account 14 105.8 100.4 0.95 63.4 0.60 Notifications 13 89.6 140.7 1.57 78.2 0.87 Upload file 11 76.7 115.1 1.50 99.8 1.30 Your changes have been saved 28 216.0 263.1 1.22 184.3 0.85 mean de/en 1.39 mean fi/en 1.08 mean character ratio de/en 1.33 de/en high 2.13 low 0.94 font 16px "Segoe UI", system-ui, sans-serifThe mean is 1.39 and it describes none of the rows.
SavebecomesSpeichernat 2.13,PasswordbecomesPasswortat 0.94, and Finnish shrinks more often than it grows on this sample. Note the character ratio, 1.33, against the pixel ratio, 1.39: counting characters understated the width by 6 points. - Step 2.
Put each string into the fixed-width button and compare
scrollWidthwithclientWidth. That difference is the text the button will hide.const STRINGS = [ ['Save', 'Speichern', 'Tallenna'], ['Settings', 'Einstellungen', 'Asetukset'], ['Delete account', 'Konto löschen', 'Poista tili'], ['Notifications', 'Benachrichtigungen', 'Ilmoitukset'], ['Upload file', 'Datei hochladen', 'Lataa tiedosto'], ]; const b = document.getElementById('btn'); const cell = (lang, t) => { b.textContent = t; const lost = b.scrollWidth - b.clientWidth; return (lost > 0 ? `${lang} +${lost}px` : `${lang} fits`).padEnd(11); }; `button clientWidth ${(b.textContent = 'x', b.clientWidth)}px\n` + STRINGS.map(([en, de, fi]) => (en.padEnd(16) + cell('en', en) + cell('de', de) + cell('fi', fi)).trimEnd()).join('\n');button clientWidth 118px Save en fits de fits fi fits Settings en fits de fits fi fits Delete account en +4px de fits fi fits Notifications en fits de +39px fi fits Upload file en fits de +13px fi fitsGerman overflows on two labels.
Delete accountoverflows by 4 px in English, which says the button was already losing text before any translation existed. - Step 3.
Derive the width each control needs from its widest locale, so the fix is a number rather than a guess.
const STRINGS = [ ['Save', 'Speichern', 'Tallenna'], ['Settings', 'Einstellungen', 'Asetukset'], ['Delete account', 'Konto löschen', 'Poista tili'], ['Notifications', 'Benachrichtigungen', 'Ilmoitukset'], ['Upload file', 'Datei hochladen', 'Lataa tiedosto'], ]; const probe = document.getElementById('probe'); const px = (t) => { probe.textContent = t; return probe.getBoundingClientRect().width; }; const b = document.getElementById('btn'); const chrome = b.offsetWidth - b.clientWidth + parseFloat(getComputedStyle(b).paddingLeft) + parseFloat(getComputedStyle(b).paddingRight); `box chrome (padding + border) ${chrome}px, current width ${getComputedStyle(b).width}\n` + 'en string widest locale text px needs\n' + STRINGS.map(([en, de, fi]) => { const w = [['en', px(en)], ['de', px(de)], ['fi', px(fi)]] .sort((x, y) => y[1] - x[1])[0]; return en.padEnd(16) + (w[0] + ' ' + [en, de, fi][['en', 'de', 'fi'].indexOf(w[0])]).padEnd(21) + w[1].toFixed(1).padStart(7) + Math.ceil(w[1] + chrome).toString().padStart(8) + 'px'; }).join('\n');box chrome (padding + border) 18px, current width 120px en string widest locale text px needs Save de Speichern 69.6 88px Settings de Einstellungen 94.3 113px Delete account en Delete account 105.8 124px Notifications de Benachrichtigungen 140.7 159px Upload file de Datei hochladen 115.1 134pxThe 120 px button holds two of the five labels.
Notificationsneeds 159 px, which is 33 percent more than the width in the stylesheet. - Step 4.
Run the same measurement against pseudo-localized text, which is what you have before any translator has been paid. This padding function accents the letters and appends a fixed 40 percent.
const FROM = 'aceilnostuxyACEILNOSTUXY'; const TO = 'àçéîĺñöšţüxýÀÇÉÎĹÑÖŠŢÜXÝ'; const pseudo = (t, pad = 0.4) => '[' + [...t].map((c) => (FROM.includes(c) ? TO[FROM.indexOf(c)] : c)).join('') + '·'.repeat(Math.ceil(t.length * pad)) + ']'; const PAIRS = [['Save', 'Speichern'], ['Settings', 'Einstellungen'], ['Notifications', 'Benachrichtigungen'], ['Upload file', 'Datei hochladen'], ['Your changes have been saved', 'Ihre Änderungen wurden gespeichert']]; const probe = document.getElementById('probe'); const px = (t) => { probe.textContent = t; return probe.getBoundingClientRect().width; }; 'en string pseudo pseudo/en de/en\n' + PAIRS.map(([en, de]) => en.padEnd(30) + pseudo(en).padEnd(21) + (px(pseudo(en)) / px(en)).toFixed(2).padStart(9) + (px(de) / px(en)).toFixed(2).padStart(8)).join('\n');en string pseudo pseudo/en de/en Save [Šàvé··] 1.51 2.13 Settings [Šéţţîñgš····] 1.41 1.66 Notifications [Ñöţîfîçàţîöñš······] 1.34 1.57 Upload file [Üpĺöàd fîĺé·····] 1.35 1.50 Your changes have been saved [Ýöür çhàñgéš hàvé bééñ šàvéd············] 1.24 1.22The pseudo strings track German on the long row and fall short on every short one. Padding by a percentage of the character count gives a smaller multiple as the string grows, and short strings are where real expansion is largest.
How to read the result
| What you see | What it means | What to do |
| --- | --- | --- |
| scrollWidth larger than clientWidth on a control | The label does not fit and the end is cut | Take the "needs" figure from step 3 and size the control to it. |
| A ratio above 2 on a short label | The worst case is a one-word button, not a paragraph | Size short controls from the measured string, never from the mean. |
| A ratio under 1 | The translation is shorter than English | Centred and stretched layouts move. Look at the container, not the text. |
| Pixel ratio above the character ratio | Wide glyphs, not extra letters | Measure in the page. A character count is not a width. |
| Every locale fits and the label still truncates | The container is narrower than the control | Check the parent with the same comparison. |
Thresholds
The W3C figure, attributed there to IBM, is a worst case for short strings rather than an average across a screen. The 28-character row above came out at 1.22, below the band that table gives for its length. Use the published number to decide how much slack a new component needs, and the measured number to decide whether this build ships.
Common mistakes
What to check next
- How to check if text is truncated: what happens to the pixels that did not fit, and how to see them.
- How to test rtl layout: the other way a localized layout fails, measured the same way.
- How to check for missing translations: a key that fell back to English measures as English and hides the expansion.
- How to check string length with emoji: why
lengthis the wrong count before it is the wrong width.
FAQ
How do I test pseudo-localization?
Pipe every string through an accent-and-pad function before render, as in step 4, then run the overflow comparison from step 2. Pseudo text shows missing keys and hard-coded strings at the same time, because anything unaccented came from somewhere other than the catalogue.
What expansion factor should I design for?
The W3C article citing IBM gives 200 to 300 percent for source strings of 10 characters or fewer. That is the figure to design a new component against. For a build that already has translations, measure them, as in step 1.
Can I measure widths without a browser?
Not reliably. Width depends on the font file, the size, the letter spacing and the shaping the browser applies. A canvas measureText in the same page and font is close. Anything outside the page is a guess.
Does text-overflow: ellipsis fix expansion?
It changes silent loss into visible loss, which is an improvement and not a fix. The label still does not fit. Size the control from the widest locale and keep the ellipsis for the strings you cannot bound.
Verified
Verified by Maks VernyChrome 152.0.7977.76Node 22.23.2
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