Text expansion clipping in fixed-width buttons
The English interface is immaculate. The German build ships a primary action reading “Zahlungsmethode ände…” and a secondary button whose label has vanished below its own border. Nothing errored, no test failed, and the only reason anyone noticed is that a customer sent a screenshot.
Text expansion is the most predictable localization defect there is — and the easiest to catch, because a padded pseudo-locale reproduces it on demand before any translation exists.
Root cause: the container was sized to English
Interfaces are laid out against the source language, and the source language is nearly always the shortest one that will ever appear in them. German UI strings typically run about a third longer than English; French and Spanish about a quarter. Short strings expand proportionally more than long ones, because a one-word English label rarely has a one-word equivalent — “Save” becomes “Speichern”, “Undo” becomes “Rückgängig machen”.
That is why buttons, tabs, chips and table headers break first, while paragraphs almost never do. A paragraph has slack in every direction. A button that was given a fixed width, or white-space: nowrap with a max-width, has none.
The failure is silent because CSS overflow is silent. overflow: hidden on the container clips the glyphs, text-overflow: ellipsis clips them politely, and neither raises anything a test could observe unless the test knows to look.
Minimal reproducible example
<button class="btn">Save changes</button>
<style>
.btn {
width: 120px; /* sized against the English label */
white-space: nowrap; /* forbids the only escape route */
overflow: hidden;
text-overflow: ellipsis;
}
</style>
Render that button with the label [!! Ŝàvé çĥàñĝéŝ······ !!] and it clips immediately. Render it with the real German string and it clips too — three weeks later, in production.
The fix: make the control size to its content
The remedy is almost always to replace the fixed width with a minimum width and let the box grow. A minimum keeps a short label from collapsing into a cramped square; automatic width gives every longer translation the room it needs.
Where the surrounding layout genuinely cannot grow — a toolbar with a fixed number of slots, a data table column — the next best option is to let the label wrap onto a second line, which costs vertical space that is usually available. Truncation is a distant third, acceptable only when the complete text is reachable another way, such as a tooltip or the row’s detail view. It is worth saying plainly that a title attribute is not an adequate substitute for visible text on touch devices, where there is no hover.
The fourth option is the one engineers forget: ask for shorter copy. If a control is genuinely space-constrained, that constraint is information the translator needs and does not have. A note on the catalogue entry — “button label, maximum 14 characters” — produces a shorter translation instead of a clipped one. That note has to live with the string, which is what the notes field in PO and XLIFF exists for.
Verification
Assert on geometry, not on text. A clipped element has a scrollWidth larger than its clientWidth, and that comparison works regardless of which locale is rendering.
import { test, expect } from '@playwright/test';
test('no clipped controls under expansion @pseudo', async ({ page }) => {
await page.goto('/en-XA/settings');
const clipped = await page.$$eval('button, .tab, th', (els) =>
els
.filter((el) => el.scrollWidth > el.clientWidth + 1) // 1px for sub-pixel rounding
.map((el) => el.textContent?.trim().slice(0, 40) ?? '')
);
expect(clipped, `clipped: ${clipped.join(' | ')}`).toHaveLength(0);
});
Run the same assertion at two viewport widths. A control that fits at 1440px and clips at 375px is a different bug with the same symptom, and separating them early saves an afternoon.
npx playwright test --grep @pseudo --project=desktop
npx playwright test --grep @pseudo --project=mobile
# Expected
# ✓ settings: 0 clipped controls (1440×900)
# ✓ settings: 0 clipped controls (375×812)
When to escalate
If controls still clip after the width fix, the constraint is coming from a parent. A grid-template-columns with fixed track sizes, a flex item with flex: 0 0 120px, or an absolutely positioned overlay will all override a child’s willingness to grow. Inspect the computed layout of the ancestor chain rather than the button.
A second cause is a font that renders wider than the one used in development. If the production build serves a different subset — or falls back because a glyph is missing from the subset, which the accented pseudo-locale is very good at provoking — measured widths change. That is a font-loading problem wearing a layout problem’s clothes; see the accent-map caveat in localization testing and pseudolocalization.
Finally, if a label is unavoidably long in one language only, the answer may be a per-locale copy decision rather than a layout change. That is a product conversation, and it is a legitimate outcome.
Tables, tabs and the places where space really is fixed
Buttons are the easy case: almost any button can grow. Three components genuinely cannot, and each has its own answer.
Table headers live in a column whose width is shared with the data below. Letting a header grow reflows every row. The workable approach is to allow the header to wrap to two lines — table headers are one of the few places where two lines reads as deliberate rather than broken — and to set a minimum column width that accommodates the longest expected header rather than the English one. Where a header is genuinely long in one language only, an abbreviation with the full text in a tooltip is acceptable for a header, because the column’s meaning is also carried by its data.
Tab strips have a horizontal budget shared across all tabs, so expansion in one pushes the others off screen. The reliable pattern is a scrollable tab strip with an overflow menu, which is a layout that works identically in every language rather than one tuned to English label lengths. What does not work is truncating tab labels: a tab is a navigation target, and a truncated target is unusable.
Fixed-height containers produce the vertical version of the same defect. A card sized to two lines of English will hold three lines of German, and the third line either overflows or is clipped. Assert on scrollHeight against clientHeight alongside the width check — the same test, one extra dimension:
const overflowing = await page.$$eval('.card, .toast, .badge', (els) =>
els.filter((el) => el.scrollHeight > el.clientHeight + 1 ||
el.scrollWidth > el.clientWidth + 1)
.map((el) => el.className));
expect(overflowing).toHaveLength(0);
In all three cases the underlying decision is the same: decide before translation whether a surface has a length budget, and if it does, tell the translator what it is. A budget nobody communicated is a budget that will be exceeded.
FAQ
How much expansion should a layout tolerate?
Design for roughly sixty percent growth on short labels and thirty on longer ones. That is deliberately more than real translations usually need, and the margin absorbs the cases that exceed the average — compound nouns in German, and languages that lack a short imperative form for common actions.
Is text-overflow: ellipsis ever the right answer?
For user-generated content in a constrained cell, yes — a file name or an email address can be arbitrarily long and no layout can accommodate every case. For interface copy that you control, it almost never is: the text is short, its length is knowable, and the space can nearly always be found.
Do right-to-left languages have the same problem?
They have a related one. Arabic and Hebrew are often shorter than English in character count but taller in line height, so the failure mode moves from horizontal clipping to vertical overlap. Test them with a mirrored pseudo-locale, and check line boxes rather than widths — see RTL and bidirectional layout engineering.
Should I test with the longest real translation instead of a pseudo-locale?
Do both, in that order. The pseudo-locale is available before any translation exists and is deliberately worse than reality, so it catches the defect early and gives a stable ceiling. The longest real translation is the ground truth and belongs in the sweep once the locale ships — it occasionally exceeds the pseudo padding for a specific string, and when it does you want a failing test rather than a screenshot from a customer.
Does a minimum width break dense layouts?
It can, if the minimum is chosen from a comfortable desktop button rather than from the tightest context the control appears in. Set the minimum from the narrowest legitimate use — usually a mobile toolbar — and let padding provide the breathing room everywhere else. A minimum that is too large is a fixed width wearing a different name.
Related
- Localization Testing & Pseudolocalization — generating the padded locale that reproduces this on demand.
- RTL & Bidirectional Layout Engineering — logical properties that keep the same rule correct in both directions.
- Fixing mirrored icons and logical CSS properties — the adjacent layout defect that shows up in the same sweep.
- PO & XLIFF Format Bridging — where a length note travels with the string.