IntlPull
Guide
17 min read

UI Translation & Localization: Text Expansion, RTL Languages & Responsive Design (2026)

Technical guide to UI translation and localization for developers. Fix text overflow, support RTL languages, handle character limits, and test multilingual interfaces.

IntlPull Team
IntlPull Team
03 Feb 2026, 11:44 AM [PST]
On this page
Summary

Technical guide to UI translation and localization for developers. Fix text overflow, support RTL languages, handle character limits, and test multilingual interfaces.

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:

EnglishGermanExpansion
SaveSpeichern+150%
DeleteLöschen+75%
SettingsEinstellungen+120%
OKOK0%

Average expansion by language:

LanguageExpansion 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:

JavaScript
const 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

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

TSX
1// 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:

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

CSS
1/* 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:

JavaScript
1function 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:

JavaScript
1// 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

JavaScript
1function 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):

CSS
1.sidebar {
2  float: left;
3  margin-right: 20px;
4  padding-left: 10px;
5  border-left: 1px solid #ccc;
6}

✅ New way (logical):

CSS
1.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:

OldNew (Logical)LTRRTL
leftinline-startleftright
rightinline-endrightleft
margin-leftmargin-inline-startmargin-leftmargin-right
padding-rightpadding-inline-endpadding-rightpadding-left
border-leftborder-inline-startborder-leftborder-right
text-align: lefttext-align: startleftright

Step 3: Mirror Layout

Flexbox:

CSS
1.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:

CSS
1.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:

CSS
1/* 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:

TSX
1function 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)

TSX
1// 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

JavaScript
1// tailwind.config.js
2module.exports = {
3  plugins: [
4    require('@tailwindcss/rtl'),
5  ],
6};
TSX
1// 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

SQL
1-- ❌ 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

SQL
1-- ✅ Account for multi-byte characters
2CREATE TABLE products (
3  name VARCHAR(200)  -- 4x safety margin
4);

Solution 2: Use TEXT types

SQL
1-- ✅ No length limit
2CREATE TABLE products (
3  name TEXT
4);

Problem: UI Display Limits

Validate character count, not byte count:

TSX
1function 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:

JavaScript
1function 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

CSS
1/* 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:

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

TSX
1function 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:

JSON
1// 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

JavaScript
1// 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

JavaScript
1// 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

JavaScript
1test('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

TSX
1// 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

TSX
1// 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

VUE
1<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

JavaScript
1// 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};
TSX
1// 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

CSS
1/* ❌ 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

CSS
1/* ❌ 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

TSX
1// ❌ Arrow always points right
2<ChevronRight />
3
4// ✅ Direction-aware
5const { dir } = useDirection();
6{dir === 'rtl' ? <ChevronLeft /> : <ChevronRight />}

4. String Concatenation for Width

JavaScript
1// ❌ 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

CSS
1/* ❌ 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

TSX
1function 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

CSS
1/* 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:

  1. Flexible layouts that handle 30% text expansion
  2. RTL support with logical CSS properties
  3. Character limits counted correctly (grapheme clusters)
  4. Responsive design for mobile + multilingual
  5. Testing with pseudo-localization, screenshots, overflow detection

Quick wins:

  • Replace all left/right with inline-start/inline-end
  • Use min-width instead of width for 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.

Tags
ui-localization
rtl
text-expansion
responsive-design
i18n
css
IntlPull Team
IntlPull Team
Engineering

Building tools to help teams ship products globally. Follow us for more insights on localization and i18n.