Internationalization in Design Systems: Beyond Translations in 2025
Most design systems treat internationalization (i18n) as a translation step at the end of the build. The result: RTL layouts that mirror exactly wrong, Arabic text rendered in fonts that display as placeholder boxes, dates formatted for one locale delivered to another, and numbers aligned to the wrong margin. True i18n isn't bolted on—it's baked into the system from day one. It's the difference between a design system that works globally and one that merely translates. This guide covers the five dimensions of design system internationalization: bidirectional text layout, locale-aware typography and spacing, script-specific fonts, Angular i18n integration, and Figma workflows for international design. By the end, you'll understand how to build design systems that serve users in Cairo, Tokyo, Mumbai, and São Paulo—not just English speakers.
learn about Angular Development
PrimeNG vs Angular Material (2026): Which to Choose?
What i18n Actually Means for a Design System
Internationalization extends far beyond swapping English strings for Arabic, Japanese, or Portuguese. A design system internationalized for 2025 must account for five distinct dimensions that span design tokens, component behavior, typography rendering, and developer patterns.
The five dimensions of design system internationalization:
- Text direction: RTL languages (Arabic, Hebrew, Urdu) flow right-to-left, requiring mirrored layouts, swapped icon directions, and bidirectional context awareness.
- Typography and script: CJK scripts (Chinese, Japanese, Korean), Arabic, and Latin scripts render at different sizes, require different line-heights, and need dedicated font families for legibility.
- Spacing and logical properties: Horizontal spacing changes meaning in RTL; margin-left becomes meaningless when the text direction flips. Logical properties solve this.
- Date, number, and currency formatting: "12/31/2025" is invalid in many countries; 1.234,56 uses a comma as the decimal separator in Germany; currency symbols belong in different positions depending on locale.
- Icons and metaphors: Arrows, chevrons, and back buttons flip in RTL. But play buttons, clocks, and logos must not. This requires intentional design and code patterns.
Most design systems fail because they assume a single language, single direction, and single script. The moment you add Arabic or Japanese, everything breaks—unless you've planned for it from the foundation.
CSS Logical Properties: The Foundation of Bidirectional Layouts
The single most important technical decision for i18n design systems is adopting CSS logical properties. These properties automatically adapt to the text direction of the document, eliminating the need for separate RTL overrides across every component.
Physical properties (direction-dependent):
margin-left,margin-rightpadding-left,padding-rightborder-left,border-rightleft,right(positioning)
Logical properties (direction-aware):
margin-inline-start,margin-inline-endpadding-block-start,padding-block-endborder-inline-start,border-inline-endinset-inline-start,inset-inline-end
When the dir="rtl" attribute is set on the HTML element, logical properties automatically adjust their behavior—no additional CSS rules needed.
Physical vs. Logical property mapping:
| Use Case | Physical (Bad for i18n) | Logical (Best Practice) |
|---|---|---|
| Left/right padding in RTL/LTR | padding-left: 16px |
padding-inline-start: 16px |
| Top/bottom spacing | margin-top: 24px |
margin-block-start: 24px |
| Borders on sides | border-right: 1px solid |
border-inline-end: 1px solid |
| Text alignment | text-align: left |
text-align: start |
| Positioning offsets | left: 0 |
inset-inline-start: 0 |
Code example: Button component using only logical properties
.btn {
display: inline-flex;
align-items: center;
gap: 8px;
/* Logical properties—work in both LTR and RTL */
padding-inline: 16px;
padding-block: 12px;
margin-inline-end: 8px;
/* Physical properties only when necessary (animations, specific layouts) */
border: 1px solid currentColor;
border-radius: 4px;
}
.btn-icon {
/* Icons don't need logical properties—they flip via SVG transforms */
width: 20px;
height: 20px;
}
/* RTL automatically applies—no separate .btn[dir="rtl"] needed */
The advantage: a single CSS rule set works for both dir="ltr" and dir="rtl". No CSS duplication, no maintenance burden.
RTL Layout Patterns in Practice
Understanding what flips and what doesn't is critical. Not everything should mirror in RTL—doing so breaks usability and looks visually wrong.
What flips (must mirror for RTL):
- Navigation menus and sidebars
- Progress bars and progress indicators
- Lists with icons on the left/right
- Breadcrumbs
- Chevrons and back buttons
- Notification badges
What doesn't flip (must stay the same for RTL):
- Clock icons and time displays
- Play/pause buttons
- Logos and brand marks
- Numeric spinners and number fields
- Charts and graphs (usually)
- Volume sliders
Implementing RTL in Angular:
Set dir="rtl" on the root <html> element based on the active locale. This is the cleanest approach—all child components inherit the text direction context.
// src/app/core/locale.service.ts
import { Injectable } from '@angular/core';
import { BehaviorSubject } from 'rxjs';
@Injectable({ providedIn: 'root' })
export class LocaleService {
private localeSubject = new BehaviorSubject<string>('en-US');
locale$ = this.localeSubject.asObservable();
setLocale(locale: string) {
this.localeSubject.next(locale);
// Update the HTML dir attribute based on locale
const isRTL = ['ar', 'he', 'ur'].some(lang => locale.startsWith(lang));
document.documentElement.dir = isRTL ? 'rtl' : 'ltr';
document.documentElement.lang = locale;
}
getLocale(): string {
return this.localeSubject.value;
}
}
In your root component:
// src/app/app.component.ts
import { Component, OnInit } from '@angular/core';
import { LocaleService } from './core/locale.service';
@Component({
selector: 'app-root',
template: `<router-outlet></router-outlet>`,
styleUrls: ['./app.component.css']
})
export class AppComponent implements OnInit {
constructor(private localeService: LocaleService) {}
ngOnInit() {
// Initialize with saved locale or default
const savedLocale = localStorage.getItem('locale') || 'en-US';
this.localeService.setLocale(savedLocale);
}
}
By setting dir on <html>, all logical properties throughout your entire component library automatically work correctly. You avoid scattering [dir="rtl"] attributes across individual components.
For internal link context, see Design Tokens in Angular, Figma, and Style Dictionary for more on managing locale-aware tokens system-wide.
Locale-Aware Design Tokens
Design tokens are the bridge between design intent and implementation. For internationalization, tokens must include locale-aware overrides—not just one-size-fits-all values.
The problem with single-value tokens:
{
"typography": {
"body": {
"fontSize": "16px",
"lineHeight": "1.5"
}
}
}
This works for Latin scripts. But Arabic text at 16px is too small; CJK text needs more line-height for character clarity. You need locale variants.
Locale-aware token structure:
{
"typography": {
"body": {
"default": {
"fontSize": "16px",
"lineHeight": "1.5"
},
"locales": {
"ar": {
"fontSize": "18px",
"lineHeight": "1.6"
},
"he": {
"fontSize": "17px",
"lineHeight": "1.6"
},
"ja": {
"fontSize": "16px",
"lineHeight": "1.8"
},
"zh": {
"fontSize": "16px",
"lineHeight": "1.8"
},
"ko": {
"fontSize": "16px",
"lineHeight": "1.8"
}
}
},
"heading1": {
"default": {
"fontSize": "32px",
"lineHeight": "1.2"
},
"locales": {
"ar": {
"fontSize": "36px",
"lineHeight": "1.3"
},
"ja": {
"fontSize": "32px",
"lineHeight": "1.4"
}
}
}
},
"spacing": {
"default": {
"xs": "4px",
"sm": "8px",
"md": "16px",
"lg": "24px"
}
}
}
In Angular, apply locale-aware tokens:
// src/app/shared/tokens.service.ts
import { Injectable } from '@angular/core';
import { LocaleService } from '../core/locale.service';
import tokens from './design-tokens.json';
@Injectable({ providedIn: 'root' })
export class TokensService {
constructor(private localeService: LocaleService) {}
getToken(path: string): any {
const locale = this.localeService.getLocale();
const keys = path.split('.');
let value = tokens;
for (const key of keys) {
value = value[key];
}
// Check for locale override
if (value.locales && value.locales[locale.substring(0, 2)]) {
return value.locales[locale.substring(0, 2)];
}
return value.default || value;
}
}
This service returns locale-specific token values when they exist, falling back to defaults. Line-height, font-size, and spacing automatically adapt based on the active locale.
For deeper exploration, read Design Tokens in Angular, Figma, and Style Dictionary.
Angular i18n Integration
Angular's @angular/localize provides a framework-level internationalization solution. Combined with locale-aware pipes, you can build runtime locale switching without separate builds.
Setting up @angular/localize:
ng add @angular/localize
Marking strings for translation:
// src/app/greeting.component.ts
import { Component } from '@angular/core';
@Component({
selector: 'app-greeting',
template: `
<h1 i18n="@@greeting">Hello, welcome to our design system</h1>
`
})
export class GreetingComponent {}
Using locale-aware pipes:
// src/app/date-example.component.ts
import { Component } from '@angular/core';
import { formatDate } from '@angular/common';
import { LocaleService } from './core/locale.service';
@Component({
selector: 'app-date-example',
template: `
<p>Today: {{ today | date: 'medium' }}</p>
<p>Currency: {{ price | currency: currencyCode }}</p>
<p>Number: {{ largeNumber | number: '1.2-2' }}</p>
`
})
export class DateExampleComponent {
today = new Date();
price = 1234.56;
largeNumber = 9876543.21;
currencyCode = 'USD';
constructor(private localeService: LocaleService) {
this.localeService.locale$.subscribe(locale => {
// Update currencyCode based on locale
this.currencyCode = this.getCurrencyForLocale(locale);
});
}
private getCurrencyForLocale(locale: string): string {
const currencyMap: { [key: string]: string } = {
'en-US': 'USD',
'fr-FR': 'EUR',
'de-DE': 'EUR',
'ar-SA': 'SAR',
'ja-JP': 'JPY',
'zh-CN': 'CNY'
};
return currencyMap[locale] || 'USD';
}
}
The built-in DatePipe, CurrencyPipe, and DecimalPipe automatically format based on Angular's active locale. Pair these with your LocaleService to enable runtime locale switching—users can change language without a full page reload.
Font Loading for International Scripts
Different scripts require different typefaces. Loading all font variations at once bloats your CSS. Smart font loading uses unicode-range subsetting and dynamic font-display values.
CJK fonts (Chinese, Japanese, Korean):
CJK fonts are large—a full Japanese font family can exceed 5MB. Use subsetting and variable fonts when possible.
/* Load only Japanese characters when needed */
@font-face {
font-family: 'Noto Sans JP';
src: url('/fonts/noto-sans-jp.woff2') format('woff2');
unicode-range: U+0000-00FF, U+3000-303F, U+3040-309F, U+30A0-30FF, U+4E00-9FFF;
font-display: swap;
font-weight: 400;
}
/* Arabic requires a font with proper right-joining support */
@font-face {
font-family: 'Noto Sans Arabic';
src: url('/fonts/noto-sans-arabic.woff2') format('woff2');
unicode-range: U+0600-06FF, U+0750-077F, U+0250-02AF;
font-display: swap;
font-weight: 400;
}
/* Latin fallback */
@font-face {
font-family: 'Inter';
src: url('/fonts/inter.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC;
font-display: swap;
font-weight: 400;
}
/* Use a font stack that includes locale-specific fonts */
body {
font-family: 'Inter', 'Noto Sans Arabic', 'Noto Sans JP', system-ui, sans-serif;
}
/* Arabic-specific adjustments */
[lang="ar"] {
font-family: 'Noto Sans Arabic', 'Inter', system-ui, sans-serif;
letter-spacing: 0.5px;
}
/* Japanese-specific adjustments */
[lang="ja"] {
font-family: 'Noto Sans JP', 'Inter', system-ui, sans-serif;
line-height: 1.8;
}
Key points:
font-display: swapallows text to display immediately using the fallback font, then swaps to your custom font when loaded.unicode-rangetells the browser to load this font family only for specific character ranges, avoiding redundant downloads.- For Arabic, ensure your font supports right-joining glyphs (not all fonts do).
- CJK fonts are large; consider serving them from a CDN with proper caching headers.
Figma RTL Design Workflow
Figma added native support for RTL text and directional auto-layout in 2024. But designing complete RTL interfaces still requires intentional patterns.
Setting up RTL frames in Figma:
- Select a frame or auto-layout group.
- In the Design panel, look for Text Direction. Change from LTR to RTL.
- Figma flips the text direction, but your layout does not automatically mirror—you must do that manually.
Mirroring auto-layout:
- For navigation bars and horizontal lists: Set the auto-layout direction to reverse for RTL variants. Create separate frames for RTL or use Figma variables to toggle layout direction.
- For icons: Swap directional icons (arrows, chevrons) with RTL versions. Use a separate icon set or flip certain icons with
transform: scaleX(-1)in Figma.
Using Figma variable modes for locale-specific tokens:
Create a "Locale" variable mode with:
- Mode: "en-US" (default)
- Mode: "ar-SA"
- Mode: "ja-JP"
Then create variables for:
- Font sizes (default, ar override, ja override)
- Line heights (default, ar override, ja override)
- Spacing tokens (if they differ by locale)
Assign these variables to components. When you switch to "ar-SA" mode, all components update to Arabic-specific typography and spacing.
This reduces manual duplication and keeps RTL designs in sync with LTR versions.
For design handoff best practices, see Figma Dev Mode & Angular Design Handoff Guide.
Date, Number, and Currency Formatting
Never hardcode date, number, or currency formats. Different locales have dramatically different conventions.
Examples of locale-specific formatting:
- Date: "12/31/2025" (US) vs. "31/12/2025" (UK, EU) vs. "2025年12月31日" (Japan)
- Number: "1,234.56" (US) vs. "1.234,56" (Germany) vs. "1 234,56" (France)
- Currency: "$1,234.56" (US) vs. "1.234,56€" (Germany) vs. "¥1,234" (Japan)
Use the Intl API or Angular pipes:
// src/app/intl-formatter.service.ts
import { Injectable } from '@angular/core';
@Injectable({ providedIn: 'root' })
export class IntlFormatterService {
formatDate(date: Date, locale: string, options?: any): string {
const formatter = new Intl.DateTimeFormat(locale, {
year: 'numeric',
month: 'long',
day: 'numeric',
...options
});
return formatter.format(date);
}
formatNumber(value: number, locale: string): string {
const formatter = new Intl.NumberFormat(locale);
return formatter.format(value);
}
formatCurrency(value: number, locale: string, currency: string): string {
const formatter = new Intl.NumberFormat(locale, {
style: 'currency',
currency: currency
});
return formatter.format(value);
}
formatList(items: string[], locale: string): string {
const formatter = new Intl.ListFormat(locale, { style: 'long', type: 'conjunction' });
return formatter.format(items);
}
}
Angular pipe for Intl.DateTimeFormat:
// src/app/shared/intl-date.pipe.ts
import { Pipe, PipeTransform } from '@angular/core';
@Pipe({ name: 'intlDate' })
export class IntlDatePipe implements PipeTransform {
transform(value: Date, locale: string, format: string = 'long'): string {
if (!value) return '';
const options: Intl.DateTimeFormatOptions = {
year: 'numeric',
month: format === 'short' ? 'short' : 'long',
day: 'numeric'
};
return new Intl.DateTimeFormat(locale, options).format(value);
}
}
// Usage: {{ today | intlDate: 'ar-SA' }}
// Output: 28 مايو 2025
This ensures dates, numbers, and currencies format correctly for every locale—no manual overrides needed.
Icon Directionality
Icons are where many internationalization projects fail. Some icons should flip for RTL; others must stay exactly the same.
Decision matrix: which icons flip?
| Icon | RTL Behavior | Reason |
|---|---|---|
| Chevron left/right | FLIP | Direction indicates navigation |
| Back button | FLIP | Navigation direction |
| Forward/next arrow | FLIP | Navigation direction |
| Play button | STAY | Direction is abstract, not visual |
| Pause button | STAY | No directional meaning |
| Clock | STAY | Clock orientation is language-neutral |
| Phone icon | STAY | Visual object, no direction |
| Menu (hamburger) | STAY | Abstract icon, no direction |
| Check mark | STAY | No directional meaning |
| X (close) | STAY | No directional meaning |
| Settings gear | STAY | Abstract rotation, no direction |
| Logo | STAY | Brand identity |
Implementing icon flipping in Angular:
// src/app/shared/icon.component.ts
import { Component, Input } from '@angular/core';
const FLIPPED_ICONS = new Set([
'chevron-left',
'chevron-right',
'arrow-left',
'arrow-right',
'back',
'forward'
]);
@Component({
selector: 'app-icon',
template: `
<svg
[class.flipped]="shouldFlip"
[attr.aria-hidden]="true"
class="icon"
>
<use [attr.xlink:href]="'#' + name"></use>
</svg>
`,
styles: [`
.icon {
display: inline-block;
width: 1em;
height: 1em;
}
.flipped {
transform: scaleX(-1);
}
[dir="rtl"] .flipped {
transform: scaleX(-1);
}
`]
})
export class IconComponent {
@Input() name: string = '';
get shouldFlip(): boolean {
const locale = document.documentElement.lang;
const isRTL = document.documentElement.dir === 'rtl';
return isRTL && FLIPPED_ICONS.has(this.name);
}
}
// Usage: <app-icon name="chevron-right"></app-icon>
// In RTL: automatically flips
This approach keeps icon directionality logic in one place, preventing scattered RTL overrides across your codebase.
Testing i18n in Design Systems
Internationalization introduces complexity; testing must be intentional and thorough.
Pseudo-localization testing:
Before translating to real languages, test with pseudo-localized text. Pseudo-localization adds accents, expands text length, and uses special characters to expose layout issues.
// src/app/core/pseudo-locale.ts
export function pseudoLocalize(text: string): string {
// Expand text by 30% (common for German and other languages)
const expanded = text + ' '.repeat(Math.ceil(text.length * 0.3));
// Add visual markers
const withMarkers = `[${expanded}]`;
// Replace vowels with accented versions
return withMarkers.replace(/[aeiou]/gi, (char) => {
const accented: { [key: string]: string } = {
a: 'á', e: 'é', i: 'í', o: 'ó', u: 'ú',
A: 'Á', E: 'É', I: 'Í', O: 'Ó', U: 'Ú'
};
return accented[char] || char;
});
}
Run your entire component library with pseudo-localized strings. If layouts break, text overflows, or truncation occurs, fix it before adding real translations.
Axe-core for bidirectional accessibility:
// src/app/core/a11y-testing.ts
import { axe, toHaveNoViolations } from 'jest-axe';
describe('RTL Accessibility', () => {
it('should have no accessibility violations in RTL', async () => {
document.documentElement.dir = 'rtl';
const results = await axe(document);
expect(results).toHaveNoViolations();
});
it('should have no violations in LTR', async () => {
document.documentElement.dir = 'ltr';
const results = await axe(document);
expect(results).toHaveNoViolations();
});
});
Visual regression testing with locale variants:
// Tests with Percy or similar VRT tools
describe('Visual Regression - Locales', () => {
['en-US', 'ar-SA', 'ja-JP'].forEach(locale => {
it(`button component renders correctly in ${locale}`, async () => {
render(<ButtonComponent locale={locale} />);
await percySnapshot(`button-${locale}`);
});
});
});
Common i18n Anti-Patterns
These mistakes are common and costly:
1. Hardcoding directional properties:
/* WRONG */
.sidebar {
margin-left: 24px;
padding-left: 16px;
border-left: 1px solid #ccc;
}
/* RIGHT */
.sidebar {
margin-inline-start: 24px;
padding-inline-start: 16px;
border-inline-start: 1px solid #ccc;
}
2. Assuming Latin-only font stacks:
/* WRONG */
body {
font-family: 'Helvetica Neue', sans-serif;
}
/* RIGHT */
body {
font-family: 'Inter', 'Noto Sans Arabic', 'Noto Sans JP', system-ui, sans-serif;
}
3. Using transform: scaleX(-1) to mirror RTL:
/* WRONG - breaks sub-pixel rendering and animation */
[dir="rtl"] .icon {
transform: scaleX(-1);
}
/* RIGHT - use CSS logical properties, flip only directional icons */
[dir="rtl"] .chevron-icon {
transform: scaleX(-1);
}
4. Locking line-height to values that clip scripts:
/* WRONG */
body {
line-height: 1.4; /* Too tight for Arabic ascenders/descenders */
}
/* RIGHT - use locale-aware tokens */
body {
line-height: 1.5;
}
[lang="ar"] {
line-height: 1.6;
}
5. Mixing physical and logical properties:
/* WRONG - inconsistent */
.card {
margin-left: 16px; /* Physical */
padding-inline: 20px; /* Logical */
border-right: 1px solid; /* Physical */
}
/* RIGHT - all logical */
.card {
margin-inline-start: 16px;
padding-inline: 20px;
border-inline-end: 1px solid;
}
A System Built for One Language Is Built for None
Internationalization is not an afterthought. It's foundational. The moment you adopt CSS logical properties, design locale-aware tokens, set up RTL testing, and establish font loading strategies, everything else becomes easier. Your components work in Cairo, Riyadh, Tokyo, Beijing, and Seoul—not through luck, but by design.
True i18n means designing for the world first, not adapting for English later. It means your design system serves users regardless of script, locale, or text direction. It means your Angular components don't need redundant RTL overrides. It means Figma modes switch typography, spacing, and icon direction simultaneously. It means dates format correctly, icons flip intelligently, and typography renders beautifully in every script.
Start with CSS logical properties. Add locale-aware tokens. Implement Angular's @angular/localize. Load fonts with unicode-range subsetting. Test with pseudo-localization. Build RTL into your component library from day one.
Your global users—and your future self—will thank you.
FAQ
What does internationalization mean for a design system?
Internationalization (i18n) in a design system means designing and building components that work correctly across languages, scripts, and locales — including RTL/LTR text direction, locale-specific typography, date and number formatting, and culturally appropriate iconography, not just text translation. True i18n is an architectural decision, not a translation layer added at the end.
What are CSS logical properties and why do they matter for i18n?
CSS logical properties like margin-inline-start, padding-block-end, and border-inline replace directional properties like margin-left and padding-bottom. They automatically flip for RTL languages, eliminating the need for separate RTL overrides across every component. A single CSS rule set works for both LTR and RTL, reducing code complexity and maintenance burden.
How do you implement RTL support in Angular?
Set the dir attribute on the root element based on the active locale, use CSS logical properties throughout your design system, and apply [dir="rtl"] scoped overrides only for properties that logical properties cannot handle, such as icon direction and transform animations. Use a LocaleService to manage locale state and update the HTML dir attribute dynamically.
How should typography scale differently for international scripts?
Latin scripts typically work well at 16px body size, but Arabic and CJK scripts require larger base sizes (18–20px) for equivalent readability, and some scripts need adjusted line-height values. Design tokens for typography should include locale-aware overrides, not one-size-fits-all values, because character complexity and rendering requirements vary significantly across scripts.
Can Figma handle RTL design natively?
Figma added RTL text support and auto-layout direction control, but complete RTL design still requires manually mirroring layouts and swapping directional icons. Using Figma variable modes for locale-specific token overrides reduces the manual effort significantly and keeps RTL and LTR designs in sync.
