The Launch That Broke Everything
The German launch was scheduled for 9 AM. At 8:55 AM, someone opened the German version.
Buttons cut off mid-word. Navigation broken. Forms unreadable. The carefully designed UI looked like a toddler's finger painting.
What happened? English "Save" (4 chars) became German "Speichern" (10 chars). The UI wasn't ready for it.
This guide teaches you how to build UIs that work in any language, from compact Japanese to verbose German to right-to-left Arabic.
The Three Hard Problems
1. Text Expansion (The German Problem)
Different languages take different amounts of space for the same meaning:
| English | German | Expansion |
|---|---|---|
| Save | Speichern | +150% |
| Delete | Löschen | +75% |
| Settings | Einstellungen | +120% |
| OK | OK | 0% |
Average expansion by language:
| Language | Expansion vs English |
|---|---|
| German | +30% |
| French | +15-20% |
| Spanish | +20-30% |
| Italian | +15-20% |
| Portuguese | +15-25% |
| Russian | +10-15% |
| Japanese | -10% to -20% (more compact) |
| Chinese | -30% (very compact) |
| Arabic | +20-25% |
2. Right-to-Left (RTL) Layouts
Arabic, Hebrew, Persian, Urdu read right-to-left. Your entire layout needs to flip.
What changes:
- Navigation: Right → Left
- Text alignment: Default right
- Icons: Mirror horizontally
- Breadcrumbs: Reverse order
- Forms: Labels on right
- Scrollbars: On left
What stays the same:
- Videos/images (don't mirror)
- Phone numbers, addresses
- Logos (usually)
- Code blocks
3. Character Limits
Twitter allows 280 characters, not bytes. But:
JavaScriptconst tweet = '中文测试'; console.log(tweet.length); // 4 characters ✅ console.log(Buffer.from(tweet, 'utf8').length); // 12 bytes ❌
Your database varchar(50) might hold 50 bytes, which is only 16 Chinese characters.
Handling Text Expansion
Strategy 1: Flexible Layouts
❌ Bad: Fixed widths
CSS.button { width: 80px; /* Breaks in German */ }
✅ Good: Auto width with padding
CSS1.button { 2 padding: 0.5rem 1rem; 3 min-width: 80px; /* Prevents tiny buttons */ 4 max-width: 200px; /* Prevents absurdly long */ 5 white-space: nowrap; /* Or allow wrapping */ 6}
Strategy 2: Truncation with Tooltips
TSX1// React component 2function TruncatedText({ text, maxLength = 30 }) { 3 const isTruncated = text.length > maxLength; 4 const displayText = isTruncated 5 ? text.slice(0, maxLength) + '...' 6 : text; 7 8 return isTruncated ? ( 9 <span title={text}>{displayText}</span> 10 ) : ( 11 <span>{displayText}</span> 12 ); 13} 14 15// Usage 16<TruncatedText text={t('product.long_description')} maxLength={50} />
CSS-only version:
CSS1.truncate { 2 overflow: hidden; 3 text-overflow: ellipsis; 4 white-space: nowrap; 5 max-width: 200px; 6} 7 8/* Multi-line truncation */ 9.truncate-multiline { 10 display: -webkit-box; 11 -webkit-line-clamp: 2; /* Show 2 lines */ 12 -webkit-box-orient: vertical; 13 overflow: hidden; 14}
Strategy 3: Responsive Typography
CSS1/* Scale font size down for longer text */ 2.button { 3 font-size: clamp(0.75rem, 2vw, 1rem); 4} 5 6/* Or use container queries (2026 browser support is good) */ 7.card { 8 container-type: inline-size; 9} 10 11.card__title { 12 font-size: 1.5rem; 13} 14 15@container (max-width: 300px) { 16 .card__title { 17 font-size: 1.2rem; /* Smaller when container is narrow */ 18 } 19}
Strategy 4: Test with Pseudo-Localization
Simulate text expansion before translating:
JavaScript1function pseudoLocalize(text) { 2 // Add brackets and extra chars to simulate expansion 3 const expanded = text 4 .split('') 5 .map(char => { 6 // Replace with accented versions 7 const map = { 8 'a': 'á', 'e': 'é', 'i': 'í', 'o': 'ó', 'u': 'ú', 9 'A': 'Á', 'E': 'É', 'I': 'Í', 'O': 'Ó', 'U': 'Ú' 10 }; 11 return map[char] || char; 12 }) 13 .join(''); 14 15 // Add 30% extra length 16 const padding = 'x'.repeat(Math.ceil(text.length * 0.3)); 17 18 return `[[${expanded}${padding}]]`; 19} 20 21// Example 22pseudoLocalize('Save'); // "[[Sávexxx]]" 23pseudoLocalize('Delete'); // "[[Delétéxxxxxx]]"
Enable in dev mode:
JavaScript1// i18n config 2const i18n = { 3 locale: process.env.PSEUDO_LOCALE ? 'xx' : 'en', 4 messages: process.env.PSEUDO_LOCALE 5 ? pseudoLocalizeMessages(enMessages) 6 : enMessages 7};
If your UI breaks with pseudo-localization, it'll break with real languages.
Implementing RTL Support
Step 1: Detect Direction
JavaScript1function getDirection(locale) { 2 const rtlLanguages = ['ar', 'he', 'fa', 'ur']; 3 const language = locale.split('-')[0]; 4 return rtlLanguages.includes(language) ? 'rtl' : 'ltr'; 5} 6 7// Set on root HTML element 8document.documentElement.dir = getDirection(currentLocale); 9document.documentElement.lang = currentLocale;
Step 2: Use Logical CSS Properties
❌ Old way (directional):
CSS1.sidebar { 2 float: left; 3 margin-right: 20px; 4 padding-left: 10px; 5 border-left: 1px solid #ccc; 6}
✅ New way (logical):
CSS1.sidebar { 2 float: inline-start; /* left in LTR, right in RTL */ 3 margin-inline-end: 20px; 4 padding-inline-start: 10px; 5 border-inline-start: 1px solid #ccc; 6}
Logical property mapping:
| Old | New (Logical) | LTR | RTL |
|---|---|---|---|
| left | inline-start | left | right |
| right | inline-end | right | left |
| margin-left | margin-inline-start | margin-left | margin-right |
| padding-right | padding-inline-end | padding-right | padding-left |
| border-left | border-inline-start | border-left | border-right |
| text-align: left | text-align: start | left | right |
Step 3: Mirror Layout
Flexbox:
CSS1.nav { 2 display: flex; 3 /* Automatically reverses in RTL */ 4} 5 6/* Force direction if needed */ 7.nav--ltr { 8 flex-direction: row; /* Always LTR */ 9}
Grid:
CSS1.grid { 2 display: grid; 3 grid-template-columns: repeat(3, 1fr); 4 /* Grid auto-places RTL-aware */ 5}
Step 4: Handle Images/Icons
Don't mirror everything:
CSS1/* Mirror icons that indicate direction */ 2.icon-arrow { 3 transform: scaleX(var(--rtl-mirror, 1)); 4} 5 6html[dir="rtl"] { 7 --rtl-mirror: -1; /* Flip horizontally */ 8} 9 10/* Don't mirror logos, photos */ 11.logo, 12.photo { 13 transform: scaleX(1) !important; 14}
React component:
TSX1function Icon({ name, mirror = false }) { 2 const { dir } = useDirection(); 3 const shouldMirror = mirror && dir === 'rtl'; 4 5 return ( 6 <svg 7 className={shouldMirror ? 'mirror-rtl' : ''} 8 style={{ 9 transform: shouldMirror ? 'scaleX(-1)' : 'none' 10 }} 11 > 12 {/* icon content */} 13 </svg> 14 ); 15} 16 17// Usage 18<Icon name="arrow-right" mirror /> // Flips in RTL 19<Icon name="user" /> // Never flips
Step 5: Test RTL Layout
DevTools trick:
JavaScript// Console command to test RTL document.documentElement.dir = 'rtl';
Or use browser extension:
- "Force RTL" extension for Chrome
- Toggle RTL/LTR on the fly
Full RTL Example (Tailwind CSS)
TSX1// Tailwind v3+ has built-in RTL support 2function Card() { 3 return ( 4 <div className=" 5 border-l-4 border-blue-500 /* LTR: left border */ 6 rtl:border-r-4 rtl:border-l-0 /* RTL: right border */ 7 pl-4 /* LTR: left padding */ 8 rtl:pr-4 rtl:pl-0 /* RTL: right padding */ 9 "> 10 <h2 className="text-left rtl:text-right"> 11 {t('title')} 12 </h2> 13 </div> 14 ); 15}
Better: Use logical properties plugin
JavaScript1// tailwind.config.js 2module.exports = { 3 plugins: [ 4 require('@tailwindcss/rtl'), 5 ], 6};
TSX1// Now use logical classes 2<div className=" 3 border-s-4 border-blue-500 /* s = start (left/right) */ 4 ps-4 /* ps = padding-start */ 5">
Handling Character Limits
Problem: Database Constraints
SQL1-- ❌ Bad: Byte limit 2CREATE TABLE products ( 3 name VARCHAR(50) -- 50 bytes, not 50 characters 4); 5 6-- A Chinese name "智能手机保护壳套装" is 24 chars but 72 bytes (UTF-8) 7-- Won't fit!
Solution 1: Increase limits
SQL1-- ✅ Account for multi-byte characters 2CREATE TABLE products ( 3 name VARCHAR(200) -- 4x safety margin 4);
Solution 2: Use TEXT types
SQL1-- ✅ No length limit 2CREATE TABLE products ( 3 name TEXT 4);
Problem: UI Display Limits
Validate character count, not byte count:
TSX1function validateInput(text, maxChars) { 2 // ✅ Count grapheme clusters (user-perceived characters) 3 const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' }); 4 const segments = Array.from(segmenter.segment(text)); 5 6 return segments.length <= maxChars; 7} 8 9// Input with live counter 10function CharLimitInput({ maxChars = 100 }) { 11 const [value, setValue] = useState(''); 12 const charCount = Array.from(new Intl.Segmenter('en', { 13 granularity: 'grapheme' 14 }).segment(value)).length; 15 16 return ( 17 <div> 18 <input 19 value={value} 20 onChange={(e) => { 21 if (validateInput(e.target.value, maxChars)) { 22 setValue(e.target.value); 23 } 24 }} 25 /> 26 <span>{charCount} / {maxChars}</span> 27 </div> 28 ); 29}
Problem: Truncation
✅ Smart truncation:
JavaScript1function smartTruncate(text, maxLength, locale = 'en') { 2 const segmenter = new Intl.Segmenter(locale, { granularity: 'word' }); 3 const segments = Array.from(segmenter.segment(text)); 4 5 if (segments.length <= maxLength) return text; 6 7 // Truncate at word boundary, not mid-word 8 let truncated = ''; 9 let count = 0; 10 11 for (const { segment } of segments) { 12 if (count + segment.length > maxLength) break; 13 truncated += segment; 14 count += segment.length; 15 } 16 17 return truncated.trim() + '...'; 18} 19 20// Example 21smartTruncate('The quick brown fox jumps', 15); 22// "The quick..." (not "The quick brow...")
Responsive Multilingual Design
Challenge: Same space, different languages
A navigation bar that fits English might overflow in German.
Solution: Responsive breakpoints per language
CSS1/* Base (English) */ 2.nav { 3 display: flex; 4 gap: 1rem; 5} 6 7/* German needs more space */ 8html[lang="de"] .nav { 9 gap: 0.5rem; /* Tighter spacing */ 10 font-size: 0.9rem; /* Slightly smaller */ 11} 12 13/* Japanese can be more compact */ 14html[lang="ja"] .nav { 15 gap: 1.5rem; /* More breathing room */ 16}
Or use container queries:
CSS1.nav { 2 container-type: inline-size; 3} 4 5.nav__item { 6 padding: 0.5rem 1rem; 7} 8 9/* When nav gets too cramped */ 10@container (max-width: 600px) { 11 .nav { 12 flex-direction: column; /* Stack vertically */ 13 } 14}
Mobile Considerations
Problem: Mobile has even less space.
TSX1function MobileNav() { 2 const { t } = useTranslation('common'); 3 const isMobile = useMediaQuery('(max-width: 768px)'); 4 5 return ( 6 <nav> 7 {isMobile ? ( 8 // Use shorter labels on mobile 9 <> 10 <a href="/settings">{t('nav.settings_short')}</a> 11 {/* en: "Settings" → de: "Einst." */} 12 </> 13 ) : ( 14 <a href="/settings">{t('nav.settings')}</a> 15 {/* en: "Settings" → de: "Einstellungen" */} 16 )} 17 </nav> 18 ); 19}
Translation files:
JSON1// en.json 2{ 3 "nav": { 4 "settings": "Settings", 5 "settings_short": "Settings" 6 } 7} 8 9// de.json 10{ 11 "nav": { 12 "settings": "Einstellungen", 13 "settings_short": "Einst." 14 } 15}
Testing Strategies
1. Visual Regression Testing
JavaScript1// Playwright example 2import { test, expect } from '@playwright/test'; 3 4const locales = ['en', 'de', 'ja', 'ar']; 5 6locales.forEach(locale => { 7 test(`Homepage looks correct in ${locale}`, async ({ page }) => { 8 await page.goto(`/${locale}`); 9 10 // Wait for fonts to load 11 await page.waitForLoadState('networkidle'); 12 13 // Screenshot test 14 await expect(page).toHaveScreenshot(`homepage-${locale}.png`, { 15 fullPage: true, 16 maxDiffPixels: 100 // Allow minor rendering differences 17 }); 18 }); 19});
2. Text Overflow Detection
JavaScript1// Automated test for text overflow 2function detectOverflow() { 3 const elements = document.querySelectorAll('button, .nav-item, .card-title'); 4 5 const overflowing = Array.from(elements).filter(el => { 6 return el.scrollWidth > el.clientWidth || 7 el.scrollHeight > el.clientHeight; 8 }); 9 10 if (overflowing.length > 0) { 11 console.error('Text overflow detected:', overflowing); 12 return false; 13 } 14 15 return true; 16} 17 18// Run in automated tests 19test('No text overflow in German', async ({ page }) => { 20 await page.goto('/de'); 21 const hasOverflow = await page.evaluate(detectOverflow); 22 expect(hasOverflow).toBe(true); 23});
3. RTL Layout Verification
JavaScript1test('RTL layout is correct for Arabic', async ({ page }) => { 2 await page.goto('/ar'); 3 4 // Check dir attribute 5 const dir = await page.$eval('html', el => el.dir); 6 expect(dir).toBe('rtl'); 7 8 // Check text alignment 9 const heading = page.locator('h1').first(); 10 const textAlign = await heading.evaluate(el => 11 window.getComputedStyle(el).textAlign 12 ); 13 expect(textAlign).toBe('right'); 14 15 // Check icon mirroring 16 const arrow = page.locator('.icon-arrow'); 17 const transform = await arrow.evaluate(el => 18 window.getComputedStyle(el).transform 19 ); 20 expect(transform).toContain('scaleX(-1)'); 21});
4. Manual Testing Checklist
- Test all languages on mobile (tight space)
- Test RTL languages (Arabic, Hebrew)
- Test CJK languages (Chinese, Japanese, Korean)
- Test longest language (usually German)
- Test most compact language (Chinese)
- Zoom to 200% (accessibility requirement)
- Test with browser dev tools → Slow 3G (images should load)
- Test form validation messages
- Test error states
- Test empty states ("No results")
Framework-Specific Solutions
React
TSX1// Direction provider 2const DirectionContext = React.createContext('ltr'); 3 4function DirectionProvider({ locale, children }) { 5 const direction = getDirection(locale); 6 7 useEffect(() => { 8 document.documentElement.dir = direction; 9 }, [direction]); 10 11 return ( 12 <DirectionContext.Provider value={direction}> 13 {children} 14 </DirectionContext.Provider> 15 ); 16} 17 18// Usage 19function App() { 20 return ( 21 <DirectionProvider locale="ar"> 22 <YourApp /> 23 </DirectionProvider> 24 ); 25}
Next.js
TSX1// app/[locale]/layout.tsx 2export default function LocaleLayout({ 3 children, 4 params: { locale } 5}) { 6 const direction = getDirection(locale); 7 8 return ( 9 <html lang={locale} dir={direction}> 10 <body>{children}</body> 11 </html> 12 ); 13}
Vue
VUE1<template> 2 <div :dir="direction"> 3 <component :is="currentComponent" /> 4 </div> 5</template> 6 7<script setup> 8import { computed } from 'vue'; 9import { useI18n } from 'vue-i18n'; 10 11const { locale } = useI18n(); 12const direction = computed(() => getDirection(locale.value)); 13</script>
Tailwind CSS
JavaScript1// tailwind.config.js 2module.exports = { 3 plugins: [ 4 function({ addVariant }) { 5 addVariant('rtl', 'html[dir="rtl"] &'); 6 addVariant('ltr', 'html[dir="ltr"] &'); 7 } 8 ] 9};
TSX1// Usage 2<div className=" 3 ml-4 rtl:mr-4 rtl:ml-0 4 text-left rtl:text-right 5">
Common Mistakes
1. Fixed-Width Containers
CSS1/* ❌ Breaks in German */ 2.button { 3 width: 100px; 4} 5 6/* ✅ Flexible */ 7.button { 8 min-width: 100px; 9 padding: 0.5rem 1rem; 10}
2. Directional Positioning
CSS1/* ❌ Always positions left */ 2.dropdown { 3 position: absolute; 4 left: 0; 5} 6 7/* ✅ Uses logical property */ 8.dropdown { 9 position: absolute; 10 inset-inline-start: 0; /* left in LTR, right in RTL */ 11}
3. Hardcoded Icons
TSX1// ❌ Arrow always points right 2<ChevronRight /> 3 4// ✅ Direction-aware 5const { dir } = useDirection(); 6{dir === 'rtl' ? <ChevronLeft /> : <ChevronRight />}
4. String Concatenation for Width
JavaScript1// ❌ Different string lengths break layout 2const label = firstName + ' ' + lastName; 3 4// ✅ Use CSS grid with equal columns 5<div className="grid grid-cols-2 gap-4"> 6 <span>{firstName}</span> 7 <span>{lastName}</span> 8</div>
5. Forgetting Mobile
CSS1/* ❌ Looks fine on desktop, breaks on mobile */ 2.nav-item { 3 padding: 1rem 2rem; 4} 5 6/* ✅ Responsive padding */ 7.nav-item { 8 padding: 0.5rem 1rem; 9} 10 11@media (min-width: 768px) { 12 .nav-item { 13 padding: 1rem 2rem; 14 } 15}
Performance Considerations
Lazy Load RTL Stylesheets
TSX1function App() { 2 const { locale } = useLocale(); 3 const direction = getDirection(locale); 4 5 useEffect(() => { 6 if (direction === 'rtl') { 7 // Load RTL styles only when needed 8 import('./styles/rtl.css'); 9 } 10 }, [direction]); 11 12 return <div dir={direction}>{/* ... */}</div>; 13}
Font Loading Strategy
CSS1/* Different fonts for different scripts */ 2body { 3 font-family: 'Inter', sans-serif; 4} 5 6html[lang="ar"] body { 7 font-family: 'Noto Sans Arabic', sans-serif; 8} 9 10html[lang="ja"] body { 11 font-family: 'Noto Sans JP', sans-serif; 12} 13 14html[lang="zh"] body { 15 font-family: 'Noto Sans SC', sans-serif; 16} 17 18/* Use font-display: swap to avoid FOIT */ 19@font-face { 20 font-family: 'Noto Sans Arabic'; 21 src: url('/fonts/NotoSansArabic.woff2') format('woff2'); 22 font-display: swap; /* Show fallback font while loading */ 23}
The Bottom Line
UI localization isn't just translation - it's:
- Flexible layouts that handle 30% text expansion
- RTL support with logical CSS properties
- Character limits counted correctly (grapheme clusters)
- Responsive design for mobile + multilingual
- Testing with pseudo-localization, screenshots, overflow detection
Quick wins:
- Replace all
left/rightwithinline-start/inline-end - Use
min-widthinstead ofwidthfor buttons - Add pseudo-localization to dev mode
- Test with Arabic (RTL) and German (text expansion)
Advanced:
- Container queries for adaptive spacing
- Different font stacks per language
- Lazy load RTL stylesheets
- Automated visual regression tests
Need help with UI localization?
Try IntlPull - Provides visual context to translators (screenshots, character limits, placement). They see how their translations look before shipping.
Or build it yourself with logical CSS properties. Just test with German first.
