<link rel="stylesheet" href="assets/fonts/inter.css"> Expert Internationalization in Design Systems: Beyond… | Viral Patel Studio Skip to main content

Expert Internationalization in Design Systems: Beyond Translations in 2025

May 28, 2025By Viral Patel

Build an internationalized design system that goes beyond translation, with RTL/LTR layouts, locale-aware tokens, Angular i18n, and CJK and Arabic fonts.

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

angular development

design systems

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-right
  • padding-left, padding-right
  • border-left, border-right
  • left, right (positioning)

Logical properties (direction-aware):

  • margin-inline-start, margin-inline-end
  • padding-block-start, padding-block-end
  • border-inline-start, border-inline-end
  • inset-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: swap allows text to display immediately using the fallback font, then swaps to your custom font when loaded.
  • unicode-range tells 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:

  1. Select a frame or auto-layout group.
  2. In the Design panel, look for Text Direction. Change from LTR to RTL.
  3. 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:

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.