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

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

    The mean is 1.39 and it describes none of the rows. Save becomes Speichern at 2.13, Password becomes Passwort at 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.

  2. Step 2.

    Put each string into the fixed-width button and compare scrollWidth with clientWidth. 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 fits

    German overflows on two labels. Delete account overflows by 4 px in English, which says the button was already losing text before any translation existed.

  3. 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     134px

    The 120 px button holds two of the five labels. Notifications needs 159 px, which is 33 percent more than the width in the stylesheet.

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

    The 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

English source strings of 10 characters or fewer expand by 200 to 300 percent on average when translated into European languages Source: https://www.w3.org/International/articles/article-text-size/
On the ten-string sample above the measured German mean is 1.39, the widest row is 2.13 and the narrowest is 0.94 Source: Measured by the step 1 command in Chrome 152 on 2026-09-12

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

Sign: A spreadsheet of character counts says the strings fit, and the button still cuts them.Cause: Characters are not pixels. On this sample German ran 1.33 times English by character count and 1.39 times by rendered width, because the letters that German adds are not the narrow ones. The gap grows with any font whose glyph widths differ more, and it disappears from view entirely in a monospace preview.
Sign: Pseudo-localization passes and the German build breaks.Cause: A pad of 40 percent of the character count produced 1.51 against a real 2.13 on Save, and 1.34 against 1.57 on Notifications. The padding is proportional to length, so it shrinks as a multiple on exactly the short labels that expand most. Pad short strings to a fixed minimum width as well as by percentage, or the check is loosest where the risk is highest.
Sign: The control overflows in the source language and nobody noticed.Cause: Delete account overflowed the 120 px button by 4 px in English. With overflow: hidden and no ellipsis there is no visual signal at all, so the defect predates translation and gets attributed to it. Run step 2 on the source locale before you look at any other.

What to check next

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.

intermediate8 minpublished updated Maks Verny