Validating RTL ARIA Attributes in Automated Tests
Switching a locale to Arabic or Hebrew should flip the document to right-to-left and swap every visible string — including ARIA labels — to the target language. In practice, teams ship dir="rtl" on the wrong element, leave ARIA labels hardcoded in English, or mismatch lang and dir, all of which break screen-reader pronunciation and reading order. This guide is part of Internationalization & Localization Testing, and it shows how to validate dir="rtl", logical CSS properties, and localized ARIA labels across locales with a Playwright test, plus a custom axe check for lang/dir mismatches, satisfying WCAG 2.2 SC 1.3.2 (Meaningful Sequence) and SC 3.1.2 (Language of Parts).
Key implementation targets:
- The document direction matches the locale: RTL locales render
dir="rtl"on<html>. - ARIA labels are localized, not hardcoded English, in every locale.
langanddiragree, and bidi content is isolated so reading order is meaningful.
Root Cause / Context
Default accessibility scanners check that lang is present and that controls have accessible names, but they cannot tell that a label is in the wrong language. A button labelled aria-label="Search" passes axe in every locale, even when the page is Arabic, because axe has no dictionary of expected translations. Likewise, dir="rtl" missing from <html> is not flagged unless it produces a contrast or overlap issue; the reading-order harm to screen-reader users is invisible to a static rule.
The other trap is physical CSS. Margins and paddings written as margin-left do not mirror in RTL, so a layout that looks correct in English collapses or overlaps in Arabic. Logical properties (margin-inline-start) mirror automatically. None of this is caught by an accessibility snapshot, so it needs a cross-locale test that loads the same page under each locale and asserts direction, localized labels, and lang/dir agreement together.
Configuration
Drive the test across a list of locales, loading each and asserting all three properties. Pass the expected translations in so the test knows what “localized” means for each locale:
// tests/rtl-aria.spec.ts
import { test, expect } from '@playwright/test';
const RTL = new Set(['ar', 'he', 'fa', 'ur']); // right-to-left locales
// Expected accessible name of the search button per locale (not hardcoded English).
const searchLabel: Record<string, string> = {
en: 'Search', ar: 'بحث', he: 'חיפוש',
};
for (const locale of ['en', 'ar', 'he']) {
test(`RTL + localized ARIA: ${locale}`, async ({ page }) => {
await page.goto(`/?lang=${locale}`);
await page.getByRole('main').waitFor();
// 1. Document direction matches the locale (SC 1.3.2 meaningful sequence).
const html = page.locator('html');
await expect(html).toHaveAttribute('dir', RTL.has(locale) ? 'rtl' : 'ltr');
// 2. lang attribute agrees with the requested locale (SC 3.1.2).
await expect(html).toHaveAttribute('lang', locale);
// 3. The search button's accessible name is the localized string, not English.
const search = page.getByRole('button', { name: searchLabel[locale] });
await expect(search).toBeVisible();
// Guard against an English label leaking through in a non-English locale.
if (locale !== 'en') {
await expect(page.getByRole('button', { name: 'Search' })).toHaveCount(0);
}
// 4. Logical properties: the layout uses inline-start, not a hardcoded left.
const usesLogical = await search.evaluate((el) => {
const cs = getComputedStyle(el);
// In RTL, margin-inline-start resolves to the right edge; assert it is non-zero
// where a physical margin-left would have stayed on the wrong side.
return cs.marginInlineStart !== '' && cs.marginInlineStart !== '0px';
});
expect(usesLogical).toBe(true);
});
}
The custom axe check catches lang/dir mismatches structurally, so it runs as part of the normal scan rather than only in the locale loop:
// lang-dir-check.js — flag elements whose dir disagrees with their lang's script
axe.configure({
checks: [{
id: 'lang-dir-agreement',
evaluate: function (node) {
const lang = (node.getAttribute('lang') || '').toLowerCase().split('-')[0];
const dir = node.getAttribute('dir');
const RTL_LANGS = ['ar', 'he', 'fa', 'ur'];
if (!lang) return true; // no lang here: not this check's job
const shouldBeRtl = RTL_LANGS.includes(lang);
if (shouldBeRtl && dir !== 'rtl') return false; // RTL language, wrong dir
if (!shouldBeRtl && dir === 'rtl') return false; // LTR language, RTL dir
return true;
},
}],
rules: [{
id: 'lang-dir-mismatch',
selector: '[lang]', // any element declaring a language
tags: ['wcag2a', 'wcag312', 'wcag132'], // SC 3.1.2 and SC 1.3.2
impact: 'serious',
all: ['lang-dir-agreement'],
metadata: { description: 'Element dir must match the script of its lang' },
}],
});
Validation
Run the suite across locales and confirm the English-leak guard fires when a translation is missing. Temporarily remove the Arabic entry from the message catalog and the ar test should fail on the hardcoded English label:
npx playwright test tests/rtl-aria.spec.ts --reporter=list
# With the 'ar' translation missing (label falls back to English):
# ✘ RTL + localized ARIA: ar
# Expected count: 0 Received count: 1 (button named "Search" found in ar)
# With all translations present and dir=rtl on <html>:
# ✓ RTL + localized ARIA: en
# ✓ RTL + localized ARIA: ar
# ✓ RTL + localized ARIA: he
For the axe check, set <html lang="ar"> without dir="rtl" on a fixture and confirm lang-dir-mismatch appears in the violations array; set dir="rtl" and confirm it moves to passes.
Edge Cases & Conditional Guards
- Mixed-direction content: an Arabic page may embed an English product name. Wrap the embedded run in an element with its own
lang="en"anddir="ltr"; thelang-dir-mismatchcheck evaluates per element, so the inner LTR span passes on its own attributes. - Bidi isolation: numbers, URLs, and code inside RTL text need
<bdi>orunicode-bidi: isolateto avoid reordering. Assert the rendered order of a known mixed string rather than trusting thatdiralone fixes it. - Locale fallback: if a translation is missing, frameworks often fall back to the default language silently. Keep the “no English label in a non-English locale” guard so a missing translation fails the build instead of shipping an English control in an Arabic UI.
Pipeline Impact
The locale loop multiplies one test into N locale runs, so the gate fails if any single locale regresses direction, language, or label localization. Because the failures are invisible to a single-locale snapshot scan, this suite is the guard against a translation key being dropped or a physical CSS property sneaking back in. Run it on the locales you actually ship, upload the per-locale Playwright report as an artifact, and treat a missing-translation fallback as a hard failure rather than a warning so untranslated controls never reach production.
Common Pitfalls
- Asserting
dir="rtl"on a wrapper<div>instead of<html>, which leaves the document default LTR for assistive tech. - Checking that an ARIA label exists without checking its language, so a hardcoded English label passes in every locale.
- Using physical CSS (
margin-left) that does not mirror, breaking layout and reading order in RTL. - Letting a missing translation fall back to English silently instead of failing the test.
- Forgetting bidi isolation for embedded numbers or Latin text, which scrambles reading order even when
diris correct.
FAQ
Why test localized ARIA labels separately from visible text?
Visible text and ARIA labels are sourced differently — a label may come from a separate aria-label string that the translation pipeline missed even when the button’s visible text was localized. Asserting the accessible name per locale catches labels that fall back to English while the visible UI looks fully translated.
Can axe-core detect an untranslated label on its own? No. axe verifies a control has an accessible name, not which language that name is in. Detecting an English label in an Arabic locale requires the cross-locale Playwright assertion that compares the accessible name against the expected translation for that locale.
Do logical CSS properties matter for accessibility or just layout? Both. A layout that overlaps or truncates in RTL because of physical margins can hide or reorder content, which harms the meaningful sequence required by SC 1.3.2. Asserting logical properties keeps the mirrored layout — and therefore the reading order — correct across directions.
Related
- Internationalization & Localization Testing — the parent section on locale-aware accessibility testing.
- Testing Internationalized Labels in Automated a11y Workflows — sibling guide focused on label translation coverage.
- Custom Rule Development & Context-Aware Testing — the broader section the lang/dir custom check belongs to.