IntlPull
Guide
11 min read

i18n for Accessibility: Building Inclusive Multilingual Applications

Complete guide to accessibility in internationalization: screen reader support, ARIA labels, RTL accessibility, font scaling, color meaning across cultures, and testing strategies.

IntlPull Team
IntlPull Team
Feb 12, 2026
On this page
Summary

Complete guide to accessibility in internationalization: screen reader support, ARIA labels, RTL accessibility, font scaling, color meaning across cultures, and testing strategies.

Accessibility (a11y) and internationalization (i18n) are both about inclusivity—ensuring everyone can use your application regardless of ability or language. When combined, they create unique challenges: screen readers must work in multiple languages, ARIA labels need translation, and visual cues must account for cultural differences. This guide covers everything developers need to build applications that are both multilingual and accessible.

The Intersection of a11y and i18n

Accessibility and internationalization share common goals but introduce complexity when combined. An accessible English interface might fail accessibility standards when translated to Arabic or Japanese due to:

  • Screen reader pronunciation differences
  • Text expansion affecting layout
  • RTL (right-to-left) navigation patterns
  • Cultural color meanings
  • Font rendering and readability
  • Form validation message clarity

Why a11y+i18n Matters

Statistics:

  • 15% of the world's population has some form of disability (WHO)
  • 75% of internet users speak a language other than English
  • Screen reader users who don't speak English face double barriers
  • Many accessibility tools have limited multilingual support

Screen Readers and Multilingual Content

Screen readers use text-to-speech (TTS) engines that must correctly pronounce content in the user's language. This requires proper language declaration and handling.

The lang Attribute is Critical

HTML
1<!-- Root language declaration -->
2<html lang="en">
3  <head>
4    <title>Welcome</title>
5  </head>
6  <body>
7    <h1>Welcome to our platform</h1>
8
9    <!-- Mixed-language content -->
10    <p>
11      The French word for "hello" is
12      <span lang="fr">bonjour</span>.
13    </p>
14
15    <!-- Entire section in different language -->
16    <section lang="es">
17      <h2>Bienvenido</h2>
18      <p>Este contenido está en español.</p>
19    </section>
20  </body>
21</html>

Why it matters:

  • Screen readers switch TTS voices based on lang
  • Without lang, French words are pronounced with English phonetics
  • Braille displays use language-specific rules

Dynamic Language Switching

TypeScript
1// React example with proper lang attribute
2import { useRouter } from 'next/router';
3
4export default function Layout({ children }: { children: React.ReactNode }) {
5  const { locale } = useRouter();
6
7  return (
8    <html lang={locale}>
9      <head>
10        <title>My App</title>
11      </head>
12      <body>{children}</body>
13    </html>
14  );
15}
16
17// Component with mixed-language content
18function ProductName({ name, language }: { name: string; language: string }) {
19  const { locale } = useRouter();
20
21  return (
22    <span lang={language !== locale ? language : undefined}>
23      {name}
24    </span>
25  );
26}

Testing Screen Reader Pronunciation

TypeScript
1// Automated test for lang attribute presence
2describe('Language attributes', () => {
3  it('should set lang on html element', () => {
4    render(<App locale="es" />);
5    expect(document.documentElement).toHaveAttribute('lang', 'es');
6  });
7
8  it('should set lang on mixed-language content', () => {
9    render(
10      <div lang="en">
11        <span lang="fr">Bonjour</span>
12      </div>
13    );
14
15    const frenchSpan = screen.getByText('Bonjour');
16    expect(frenchSpan).toHaveAttribute('lang', 'fr');
17  });
18});

Manual Testing Checklist:

  • Test with NVDA (Windows, free)
  • Test with JAWS (Windows, commercial)
  • Test with VoiceOver (macOS/iOS, built-in)
  • Test with TalkBack (Android, built-in)
  • Verify pronunciation in target languages
  • Test language switching during navigation

ARIA Labels and Translation

ARIA (Accessible Rich Internet Applications) attributes provide semantic information to assistive technologies. These must be translated like any visible text.

Translating aria-label

TypeScript
1import { useIntl } from 'react-intl';
2
3function CloseButton() {
4  const intl = useIntl();
5
6  return (
7    <button
8      aria-label={intl.formatMessage({ id: 'button.close' })}
9      onClick={handleClose}
10    >
11      <XIcon />
12    </button>
13  );
14}
15
16// Translation files
17// en.json
18{
19  "button.close": "Close"
20}
21
22// es.json
23{
24  "button.close": "Cerrar"
25}
26
27// ar.json
28{
29  "button.close": "إغلاق"
30}

aria-describedby with Translations

TypeScript
1function PasswordInput() {
2  const intl = useIntl();
3  const descriptionId = 'password-description';
4
5  return (
6    <div>
7      <label htmlFor="password">
8        {intl.formatMessage({ id: 'form.password.label' })}
9      </label>
10
11      <input
12        id="password"
13        type="password"
14        aria-describedby={descriptionId}
15      />
16
17      <p id={descriptionId}>
18        {intl.formatMessage({ id: 'form.password.requirements' })}
19      </p>
20    </div>
21  );
22}
23
24// Translation files
25// en.json
26{
27  "form.password.label": "Password",
28  "form.password.requirements": "Must be at least 8 characters with 1 number and 1 special character"
29}
30
31// es.json
32{
33  "form.password.label": "Contraseña",
34  "form.password.requirements": "Debe tener al menos 8 caracteres con 1 número y 1 carácter especial"
35}

Dynamic ARIA Live Regions

TypeScript
1function StatusMessage({ type, message }: { type: 'error' | 'success'; message: string }) {
2  const intl = useIntl();
3
4  return (
5    <div
6      role="status"
7      aria-live="polite"
8      aria-atomic="true"
9    >
10      <span class="sr-only">
11        {type === 'error'
12          ? intl.formatMessage({ id: 'aria.error' })
13          : intl.formatMessage({ id: 'aria.success' })}
14      </span>
15      {message}
16    </div>
17  );
18}
19
20// Translation files
21// en.json
22{
23  "aria.error": "Error:",
24  "aria.success": "Success:"
25}
26
27// Screen reader will announce: "Error: Your password is incorrect"

Common ARIA Translation Pitfalls

TypeScript
1// ❌ BAD: Hardcoded English ARIA label
2<button aria-label="Close">
3  <XIcon />
4</button>
5
6// ✅ GOOD: Translated ARIA label
7<button aria-label={t('button.close')}>
8  <XIcon />
9</button>
10
11// ❌ BAD: Forgetting to translate aria-describedby content
12<input aria-describedby="help-text" />
13<p id="help-text">Enter your email address</p>
14
15// ✅ GOOD: Translated description
16<input aria-describedby="help-text" />
17<p id="help-text">{t('form.email.help')}</p>
18
19// ❌ BAD: Missing lang on ARIA content
20<div role="alert">
21  Error: Invalid input
22</div>
23
24// ✅ GOOD: lang attribute set
25<div role="alert" lang={currentLocale}>
26  {t('error.invalid_input')}
27</div>

RTL (Right-to-Left) Accessibility

RTL languages (Arabic, Hebrew, Persian, Urdu) require special handling for accessibility.

Directional Navigation

CSS
1/* LTR: Tab order left → right */
2/* RTL: Tab order right → left (automatic with dir="rtl") */
3
4html[dir="rtl"] {
5  /* Mirror layout automatically */
6}
7
8/* Flexbox direction handling */
9.navigation {
10  display: flex;
11  flex-direction: row; /* Automatically reverses in RTL */
12}
13
14/* Logical properties (preferred) */
15.card {
16  margin-inline-start: 1rem; /* LTR: margin-left, RTL: margin-right */
17  padding-inline-end: 2rem;  /* LTR: padding-right, RTL: padding-left */
18  border-inline-start: 2px solid;
19}
20
21/* Physical properties (avoid) */
22.card-old {
23  margin-left: 1rem; /* Won't flip in RTL */
24  padding-right: 2rem; /* Wrong direction in RTL */
25}

Keyboard Navigation in RTL

TypeScript
1// Handle arrow key navigation with RTL awareness
2function useRTLKeyboard(onNavigate: (direction: 'next' | 'prev') => void) {
3  const { locale } = useRouter();
4  const isRTL = ['ar', 'he', 'fa', 'ur'].includes(locale);
5
6  const handleKeyDown = (e: KeyboardEvent) => {
7    if (e.key === 'ArrowRight') {
8      onNavigate(isRTL ? 'prev' : 'next');
9    } else if (e.key === 'ArrowLeft') {
10      onNavigate(isRTL ? 'next' : 'prev');
11    }
12  };
13
14  return { handleKeyDown };
15}
16
17// Usage in carousel
18function Carousel({ items }: { items: string[] }) {
19  const [current, setCurrent] = useState(0);
20  const { handleKeyDown } = useRTLKeyboard((direction) => {
21    setCurrent(prev =>
22      direction === 'next'
23        ? (prev + 1) % items.length
24        : (prev - 1 + items.length) % items.length
25    );
26  });
27
28  return (
29    <div
30      role="region"
31      aria-roledescription="carousel"
32      onKeyDown={handleKeyDown}
33      tabIndex={0}
34    >
35      {/* carousel content */}
36    </div>
37  );
38}

Focus Indicators in RTL

CSS
1/* Focus indicators must respect directionality */
2.button {
3  position: relative;
4}
5
6/* LTR: focus bar on left */
7[dir="ltr"] .button:focus::before {
8  content: '';
9  position: absolute;
10  left: 0;
11  top: 0;
12  bottom: 0;
13  width: 4px;
14  background: var(--focus-color);
15}
16
17/* RTL: focus bar on right */
18[dir="rtl"] .button:focus::before {
19  left: auto;
20  right: 0;
21}
22
23/* Better: Use logical properties */
24.button:focus::before {
25  content: '';
26  position: absolute;
27  inset-inline-start: 0;
28  inset-block: 0;
29  inline-size: 4px;
30  background: var(--focus-color);
31}

Font Sizing and Script Readability

Different scripts have different readability requirements and render differently at the same font size.

Script-Specific Font Sizing

CSS
1/* Base size for Latin scripts */
2:root {
3  --font-size-base: 16px;
4  --line-height-base: 1.5;
5}
6
7/* Arabic script needs larger size for same readability */
8:root[lang="ar"],
9:root[lang="fa"],
10:root[lang="ur"] {
11  --font-size-base: 18px;
12  --line-height-base: 1.8;
13}
14
15/* Japanese/Chinese can use slightly smaller size */
16:root[lang="ja"],
17:root[lang="zh"],
18:root[lang="ko"] {
19  --font-size-base: 15px;
20  --line-height-base: 1.7;
21}
22
23/* Thai script needs more vertical space */
24:root[lang="th"] {
25  --font-size-base: 16px;
26  --line-height-base: 2.0;
27}
28
29body {
30  font-size: var(--font-size-base);
31  line-height: var(--line-height-base);
32}

Respecting User Preferences

CSS
1/* Honor user's font-size preference */
2@media (prefers-reduced-motion: no-preference) {
3  html {
4    font-size: 100%; /* Use browser default (usually 16px) */
5  }
6}
7
8/* Scale all sizes with user preference */
9.text-sm {
10  font-size: 0.875rem; /* Scales with root font-size */
11}
12
13.text-base {
14  font-size: 1rem;
15}
16
17.text-lg {
18  font-size: 1.125rem;
19}
20
21/* ❌ BAD: Pixel values don't scale */
22.text-fixed {
23  font-size: 14px; /* Won't respond to user zoom */
24}

Testing Font Rendering

TypeScript
1// Visual regression test for font rendering
2import { test, expect } from '@playwright/test';
3
4test.describe('Font rendering across languages', () => {
5  const languages = ['en', 'es', 'ar', 'ja', 'th'];
6
7  for (const lang of languages) {
8    test(`renders ${lang} text correctly`, async ({ page }) => {
9      await page.goto(`/?locale=${lang}`);
10
11      // Wait for font loading
12      await page.waitForLoadState('networkidle');
13
14      // Screenshot comparison
15      await expect(page).toHaveScreenshot(`homepage-${lang}.png`, {
16        fullPage: true,
17        animations: 'disabled'
18      });
19    });
20  }
21});

Color and Icon Meaning Across Cultures

Colors and icons carry different meanings in different cultures, which affects accessibility.

Color Meaning by Culture

ColorWesternChinaIndiaMiddle East
RedDanger/StopLuck/CelebrationPurity/FertilityDanger
GreenSuccess/GoInfidelityFertilityIslam/Luck
WhitePurityMourningPurityPurity
YellowCautionRoyaltySacredMourning
BlueCalm/TrustImmortalityKrishnaProtection

Accessible Color Patterns

TypeScript
1// Don't rely on color alone for status
2function StatusBadge({ status }: { status: 'success' | 'warning' | 'error' }) {
3  const intl = useIntl();
4
5  const statusConfig = {
6    success: {
7      color: 'green',
8      icon: <CheckIcon />,
9      label: intl.formatMessage({ id: 'status.success' })
10    },
11    warning: {
12      color: 'yellow',
13      icon: <AlertIcon />,
14      label: intl.formatMessage({ id: 'status.warning' })
15    },
16    error: {
17      color: 'red',
18      icon: <ErrorIcon />,
19      label: intl.formatMessage({ id: 'status.error' })
20    }
21  };
22
23  const config = statusConfig[status];
24
25  return (
26    <div
27      className={`badge badge-${config.color}`}
28      role="status"
29      aria-label={config.label}
30    >
31      {config.icon}
32      <span>{config.label}</span>
33    </div>
34  );
35}

Icon Localization

TypeScript
1// Some icons need cultural adaptation
2function MailIcon({ locale }: { locale: string }) {
3  // Western mail icon (envelope)
4  if (['en', 'es', 'fr', 'de'].includes(locale)) {
5    return <EnvelopeIcon />;
6  }
7
8  // Japanese mail icon (stylized 〒)
9  if (locale === 'ja') {
10    return <JapaneseMailIcon />;
11  }
12
13  return <EnvelopeIcon />;
14}
15
16// Calendar icons: different first day of week
17function CalendarIcon({ locale }: { locale: string }) {
18  const firstDayOfWeek = locale === 'en-US' ? 0 : 1; // US: Sunday, most others: Monday
19
20  return (
21    <svg aria-hidden="true">
22      {/* Render calendar with correct first day */}
23    </svg>
24  );
25}

Form Validation and Error Messages

Form errors must be accessible and properly localized.

Accessible Error Messages

TypeScript
1function FormField({
2  label,
3  error,
4  children
5}: {
6  label: string;
7  error?: string;
8  children: React.ReactNode;
9}) {
10  const errorId = error ? `${label}-error` : undefined;
11
12  return (
13    <div>
14      <label htmlFor={label}>{label}</label>
15
16      {React.cloneElement(children as React.ReactElement, {
17        id: label,
18        'aria-invalid': !!error,
19        'aria-describedby': errorId
20      })}
21
22      {error && (
23        <p
24          id={errorId}
25          role="alert"
26          class="error-message"
27        >
28          {error}
29        </p>
30      )}
31    </div>
32  );
33}
34
35// Usage with translation
36function RegistrationForm() {
37  const intl = useIntl();
38  const [errors, setErrors] = useState<Record<string, string>>({});
39
40  const validateEmail = (email: string) => {
41    if (!email) {
42      return intl.formatMessage({ id: 'validation.email.required' });
43    }
44    if (!/^[^s@]+@[^s@]+.[^s@]+$/.test(email)) {
45      return intl.formatMessage({ id: 'validation.email.invalid' });
46    }
47    return undefined;
48  };
49
50  return (
51    <form>
52      <FormField
53        label={intl.formatMessage({ id: 'form.email.label' })}
54        error={errors.email}
55      >
56        <input type="email" />
57      </FormField>
58    </form>
59  );
60}

Error Summary for Screen Readers

TypeScript
1function ErrorSummary({ errors }: { errors: Record<string, string> }) {
2  const intl = useIntl();
3  const errorEntries = Object.entries(errors);
4
5  if (errorEntries.length === 0) return null;
6
7  return (
8    <div
9      role="alert"
10      aria-live="assertive"
11      class="error-summary"
12      tabIndex={-1}
13      ref={el => el?.focus()} // Focus on render
14    >
15      <h2>{intl.formatMessage({ id: 'form.errors.heading' })}</h2>
16      <ul>
17        {errorEntries.map(([field, message]) => (
18          <li key={field}>
19            <a href={`#${field}`}>{message}</a>
20          </li>
21        ))}
22      </ul>
23    </div>
24  );
25}
26
27// Translation files
28// en.json
29{
30  "form.errors.heading": "There are {count} errors in your form",
31  "validation.email.required": "Email is required",
32  "validation.email.invalid": "Please enter a valid email address"
33}

Testing a11y + i18n

Automated Testing

TypeScript
1// Jest + Testing Library + axe-core
2import { render } from '@testing-library/react';
3import { axe, toHaveNoViolations } from 'jest-axe';
4import { IntlProvider } from 'react-intl';
5
6expect.extend(toHaveNoViolations);
7
8describe('Accessibility + i18n tests', () => {
9  const languages = ['en', 'es', 'ar', 'ja'];
10
11  languages.forEach(locale => {
12    it(`should have no a11y violations in ${locale}`, async () => {
13      const messages = await import(`./translations/${locale}.json`);
14
15      const { container } = render(
16        <IntlProvider locale={locale} messages={messages}>
17          <App />
18        </IntlProvider>
19      );
20
21      const results = await axe(container);
22      expect(results).toHaveNoViolations();
23    });
24
25    it(`should have correct lang attribute for ${locale}`, () => {
26      const messages = await import(`./translations/${locale}.json`);
27
28      render(
29        <IntlProvider locale={locale} messages={messages}>
30          <App />
31        </IntlProvider>
32      );
33
34      expect(document.documentElement).toHaveAttribute('lang', locale);
35    });
36  });
37});

Manual Testing Checklist

Screen Reader Testing:

  • All content is announced correctly
  • Language switching is seamless
  • ARIA labels are translated
  • Mixed-language content is pronounced correctly

Keyboard Navigation:

  • Tab order is logical in both LTR and RTL
  • Arrow keys work correctly in RTL
  • Focus indicators are visible
  • Skip links work in all languages

Visual Testing:

  • Text doesn't overflow containers
  • Font rendering is clear
  • Color contrast meets WCAG AA (4.5:1 for text)
  • Icons are culturally appropriate

Form Testing:

  • Error messages are clear and translated
  • Required field indicators are visible
  • Validation works in all languages
  • Error summary is accessible

IntlPull's a11y Features

IntlPull helps maintain accessibility across translations:

1. ARIA Label Tracking

IntlPull automatically detects ARIA-related keys:

JavaScript
1// IntlPull recognizes these patterns as ARIA content
2{
3  "button.close.aria": "Close dialog",
4  "form.email.aria.description": "Enter your work email address",
5  "status.loading.aria": "Loading content, please wait"
6}

2. Length Warnings for Accessibility

IntlPull warns when translations might break accessibility:

  • ARIA labels over 100 characters
  • Button text over 50 characters
  • Alt text over 150 characters

3. RTL Preview Mode

IntlPull's editor includes RTL preview for Arabic, Hebrew, Persian:

  • Visual preview of RTL layout
  • Keyboard navigation testing
  • Text direction validation

4. Screen Reader Testing Notes

Add screen reader test results to translations:

YAML
1translations:
2  button.close:
3    en: "Close"
4    es: "Cerrar"
5    notes:
6      - "Tested with NVDA 2025.1 - announces correctly"
7      - "Tested with JAWS 2025 - announces correctly"

Frequently Asked Questions

Q: Do I need to translate ARIA labels?

Yes, absolutely. Screen reader users in non-English locales need ARIA content in their language. Untranslated ARIA labels create confusing experiences.

Q: How do I test screen reader pronunciation?

Use free tools like NVDA (Windows) or built-in VoiceOver (macOS/iOS) with your target language's TTS voice installed. Test actual devices when possible.

Q: Should I use different icons for different cultures?

Only when the icon's meaning is unclear or offensive. Most universal icons (home, search, settings) work globally. Test with native users.

Q: How do I handle text expansion in fixed-width layouts?

Use flexible layouts (flexbox, grid) and test with longest translations. Reserve 30-40% extra space for German, 50%+ for some Asian languages when transliterated.

Q: What color contrast ratio should I target?

WCAG AA requires 4.5:1 for normal text, 3:1 for large text. Test contrast in all languages as font rendering affects perceived contrast.

Q: How do I test RTL keyboard navigation?

Set dir="rtl" on the HTML element, use Arabic or Hebrew locale, and test with keyboard only. Arrow keys should feel natural for RTL users.

Q: Can I automate a11y+i18n testing?

Partially. Use axe-core for WCAG violations and jest for lang attributes, but manual screen reader testing and cultural review are essential.

Q: How does IntlPull help with accessibility?

IntlPull tracks ARIA content, warns about length issues, provides RTL preview, and allows screen reader test notes—ensuring translations maintain accessibility.

Conclusion

Building accessible multilingual applications requires attention to:

  1. Proper language declaration with lang attributes
  2. Translated ARIA labels for all assistive technology content
  3. RTL-aware navigation and layout
  4. Script-appropriate font sizing and rendering
  5. Cultural color considerations with non-color indicators
  6. Accessible error messages in all languages
  7. Comprehensive testing with screen readers and keyboards

Tools like IntlPull integrate accessibility best practices into the translation workflow, helping teams maintain both a11y and i18n standards. Remember: accessibility and internationalization aren't features—they're requirements for inclusive applications.

Tags
accessibility
a11y
i18n
inclusive
screen-reader
aria
localization
IntlPull Team
IntlPull Team
Engineering

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