Validating RTL ARIA Attributes in Automated Tests
axe-core ships no rule for the dir attribute. A page can render Arabic with a left-to-right document direction, physical margins that refuse to mirror, and an order reference whose digits have jumped to the wrong side of their prefix, and every scanner in the pipeline will report zero violations. This guide is part of Internationalization & Localization Testing, and it covers the three assertions that catch direction defects — dir="rtl" on the root element, CSS that genuinely mirrors, and bidi isolation around embedded Latin and numeric runs — plus a custom axe check that flags any element whose declared language disagrees with its resolved direction, together satisfying WCAG 2.2 SC 1.3.2 (Meaningful Sequence) and SC 3.1.2 (Language of Parts).
Root Cause
The language rules in axe-core are html-has-lang, html-lang-valid, html-xml-lang-mismatch, and valid-lang. Every one of them evaluates lang. None of them looks at dir, because direction is not, on its own, a WCAG failure that a static rule can assert: a page may legitimately be left-to-right in a right-to-left language for a code sample or a transliteration table. The consequence is that the single most consequential right-to-left attribute in the document is entirely ungated. Shipping <html lang="ar"> without dir="rtl" produces a page where assistive technology walks the content in the wrong sequence, :dir() selectors match the wrong branch, and every logical CSS property resolves to the physical edge opposite the one the design intended.
The second trap is that CSS direction and DOM direction are separable. Setting direction: rtl in a stylesheet flips visual rendering, so the page looks correct in a screenshot and in manual review. It does not change the dir attribute, which is what the accessibility tree, Element.dir, :dir(), and <bdi>'s automatic direction detection read. Teams reach for the CSS route because it is a one-line change in a theme file, then discover months later that screen-reader reading order never flipped. A test that asserts computed style alone reproduces the same blind spot; the attribute has to be asserted explicitly, and it has to be asserted on <html> rather than on a layout wrapper, because a wrapper leaves the document default in place for anything rendered outside it — dialogs in the top layer, portalled tooltips, and the <body> scroll container.
The third is that mirroring is not automatic. margin-left: 24px stays on the left in a right-to-left document; margin-inline-start: 24px moves to the right. A component built with physical properties keeps its icon on the geometric left while the text flows right-to-left, so the label and its icon separate, adjacent controls overlap, and content that was legible in English clips in Arabic. This is a reading-order harm as well as a visual one, which is why SC 1.3.2 applies. Finally, embedded Latin and numeric runs reorder. The Unicode bidirectional algorithm treats the space between a Latin word and a digit sequence as a neutral character; inside a right-to-left paragraph that neutral resolves to right-to-left, which splits one visual unit into two runs and places them in the reverse of the intended order. An order reference reading INV 88/2024 in the catalogue renders as 88/2024 INV on screen and in the accessible name unless something isolates it.
Configuration
Drive one test across the locales actually shipped, reading the expected direction from a script table. Three assertions run in every locale; two more run only in right-to-left locales, where the mirroring and bidi defects exist.
// tests/i18n-a11y/rtl-direction.spec.ts
import { test, expect } from '@playwright/test';
const RTL = new Set(['ar', 'he', 'fa', 'ur']); // right-to-left script languages
for (const locale of ['en', 'ar', 'he']) {
test(`direction and bidi integrity: ${locale}`, async ({ page }) => {
await page.goto(`/?lang=${locale}`);
await page.getByRole('main').waitFor();
const expectedDir = RTL.has(locale) ? 'rtl' : 'ltr';
// 1. dir must be an attribute on <html> so the top layer inherits it too.
const html = page.locator('html');
await expect(html).toHaveAttribute('dir', expectedDir);
await expect(html).toHaveAttribute('lang', locale);
// 2. A wrapper re-declaring the document direction is the CSS-only smell.
await expect(page.locator(`body [dir="${expectedDir}"]:not([lang])`)).toHaveCount(0);
if (expectedDir === 'ltr') return;
// 3. Mirroring: the leading icon must render right of its label in RTL.
const box = await page.getByTestId('save-button').evaluate((el) => {
const icon = el.querySelector('[data-slot="icon"]')!.getBoundingClientRect();
const label = el.querySelector('[data-slot="label"]')!.getBoundingClientRect();
return { iconLeft: icon.left, labelLeft: label.left };
});
expect(box.iconLeft).toBeGreaterThan(box.labelLeft);
// 4. Physical inline offsets that failed to mirror. In RTL an authored
// margin-left leaves margin-inline-start at 0 while margin-left is not.
const unmirrored = await page.evaluate(() =>
[...document.querySelectorAll<HTMLElement>('[data-mirror]')]
.filter((el) => {
const cs = getComputedStyle(el);
return cs.marginInlineStart === '0px' && cs.marginLeft !== '0px';
})
.map((el) => el.dataset.mirror ?? el.tagName.toLowerCase())
);
expect(unmirrored, `physical margins in RTL: ${unmirrored.join(', ')}`).toEqual([]);
// 5. Bidi isolation: an isolated run keeps its prefix left of its digits.
const order = await page.getByTestId('order-ref').evaluate((el) => {
const node = el.firstChild as Text;
const text = node.data;
const leftEdge = (needle: string) => {
const range = document.createRange();
const at = text.indexOf(needle);
range.setStart(node, at);
range.setEnd(node, at + needle.length);
return range.getBoundingClientRect().left;
};
return { prefix: leftEdge('INV'), digits: leftEdge('88/2024') };
});
// Without isolation the neutral space resolves RTL and the runs swap.
expect(order.prefix).toBeLessThan(order.digits);
});
}
Assertion 4 is worth reading twice, because it works only in a right-to-left document and that is exactly why it is useful. In left-to-right rendering margin-inline-start and margin-left resolve to the same used value, so the comparison is meaningless. In right-to-left, margin-inline-start resolves to the right edge; an element whose stylesheet authored margin-left: 24px therefore reports marginInlineStart: '0px' alongside marginLeft: '24px', which is a precise fingerprint for a physical property that did not mirror. Tagging mirror-critical components with data-mirror keeps the scan scoped to elements where the design actually depends on mirroring, instead of flagging every decorative offset in the page.
Bidi isolation is the assertion teams skip, and it is the one that shows up in support tickets as “the order number is wrong”. Wrapping the embedded run in <bdi> is the cheapest fix, because <bdi> carries unicode-bidi: isolate and dir="auto" by default, so the browser detects the run’s own direction and pins it as a single unit. An explicitly directioned span with unicode-bidi: isolate works equally well when the run’s direction is known. The Unicode isolate controls U+2066 and U+2069 are the option when the string arrives from a service and cannot be wrapped in markup, but they become part of the accessible name and must be stripped before any name comparison.
The structural half of the problem — an element declaring a language whose script disagrees with its resolved direction — is a rule, not a test, because it applies to every element on every page. Register it as a custom axe check so it runs inside the normal scan.
// axe/lang-dir-agreement.js — load before axe.run() in the page context
axe.configure({
checks: [{
id: 'lang-dir-agreement',
evaluate: function (node) {
const lang = (node.getAttribute('lang') || '').toLowerCase().split('-')[0];
if (!lang) return true; // no lang here: not this check's job
const RTL_LANGS = ['ar', 'he', 'fa', 'ur', 'yi', 'dv'];
const shouldBeRtl = RTL_LANGS.indexOf(lang) !== -1;
const declared = node.getAttribute('dir');
// dir is inherited, so the resolved value is what actually applies here.
const resolved = declared || window.getComputedStyle(node).direction;
this.data({ lang: lang, declared: declared, resolved: resolved });
if (shouldBeRtl && resolved !== 'rtl') return false; // RTL script, LTR direction
if (!shouldBeRtl && resolved === 'rtl') return false; // LTR script, RTL direction
// The root must carry dir explicitly; inheriting it is not an option there.
if (node === document.documentElement && !declared) return false;
return true;
},
metadata: {
impact: 'serious',
messages: {
pass: 'Element direction matches the script of its lang attribute',
fail: 'Element declares lang="${data.lang}" but resolves to dir="${data.resolved}"',
},
},
}],
rules: [{
id: 'lang-dir-mismatch',
selector: '[lang]', // any element declaring a language
tags: ['wcag2a', 'wcag132', 'wcag312'], // SC 1.3.2 and SC 3.1.2
all: ['lang-dir-agreement'],
metadata: {
description: 'Element dir must match the script direction of its lang attribute',
help: 'Set dir="rtl" on elements whose lang is a right-to-left language',
},
}],
});
Reading the resolved direction rather than the attribute alone is what makes the rule usable on real markup. dir is inherited, so an Arabic <span lang="ar"> nested inside a correctly configured <html dir="rtl"> has no attribute of its own and would fail a naive attribute comparison. The root element is the deliberate exception: inheritance has nowhere to come from, so an absent attribute there is always a defect. Packaging and shipping this rule to other repositories follows the same path as any shared rule, including the fixture-based unit tests described in the guide on unit testing custom axe rules with Jest fixtures.
Validation
Break each assertion in turn and confirm the expected failure. Removing dir="rtl" from the root should fail assertion 1; changing one component’s margin-inline-start to margin-left should fail assertion 4; unwrapping the order reference should fail assertion 5.
npx playwright test tests/i18n-a11y/rtl-direction.spec.ts --reporter=list
# With margin-inline-start swapped for margin-left on the toolbar:
# ✓ direction and bidi integrity: en
# ✘ direction and bidi integrity: ar
# physical margins in RTL: toolbar-actions
# Expected: [] Received: ["toolbar-actions"]
# With the <bdi> wrapper removed from the order reference:
# ✘ direction and bidi integrity: he
# expect(order.prefix).toBeLessThan(order.digits)
# Expected: < 412 Received: 486
# With everything correct:
# ✓ direction and bidi integrity: en
# ✓ direction and bidi integrity: ar
# ✓ direction and bidi integrity: he
Validate the axe rule against fixtures rather than the live app, so the verdicts are unambiguous. Serve a fixture with <html lang="ar"> and no dir, run the scan, and assert that lang-dir-mismatch appears in violations with the root element as its target. Add dir="rtl" and assert the same rule id moves to passes. Then serve a fixture with <span lang="en"> inside an <html lang="ar" dir="rtl"> document and no dir on the span; the rule must flag it, because an English run resolves to right-to-left through inheritance. That third fixture is the one that regresses when someone simplifies the resolved-direction logic back to an attribute read.
Edge Cases and Conditional Guards
- Shadow roots and the top layer. A dialog rendered into the top layer or a component inside an open shadow root inherits
dirfrom the document, so a wrapper-leveldirleaves both left-to-right while the page body looks correct. Run assertion 1 with a dialog open, and include shadow hosts in the axe context so[lang]elements inside open roots are evaluated; closed roots are unreachable and need a component-level test instead. - Deliberate direction overrides. Code samples, transliteration tables, and phone-number fields are legitimately left-to-right inside a right-to-left page. These will trip
lang-dir-mismatchif they carry alangattribute. Mark them with a data attribute and exclude that selector from the rule’sselectorrather than disabling the rule, so the exemption is visible in the configuration and reviewable in a diff. - Auto-detected direction.
dir="auto"and<bdi>resolve direction from the first strong character of their content, which means an Arabic field that a user fills with a Latin string flips at runtime. The resolved-direction read handles it correctly, but a snapshot taken before the content loads records the wrong verdict; wait for the content to render before scanning, and never assert ondir="auto"elements while they are empty.
Pipeline Impact
The locale loop turns one spec into N runs, and only the right-to-left legs execute assertions 3 through 5, so the added cost is roughly one page load per right-to-left locale — typically under fifteen seconds for Arabic and Hebrew together. That is cheap enough to keep on the pull-request path rather than deferring to a nightly job, which matters because direction regressions arrive through stylesheet refactors that touch no locale file and would sail past a copy-focused review.
The custom rule changes the shape of the scan output, not its cost. Because lang-dir-mismatch carries impact: 'serious', a pipeline that fails on critical only will report it without blocking; a pipeline that fails on serious will block. Decide that deliberately, and if the rule is being introduced into a codebase with existing right-to-left debt, register it in a warning tier first and count the findings before promoting it. New rule ids also break any baseline file keyed by rule id, so land it alongside a baseline refresh using the approach in the guide on versioning custom rules without breaking existing pipelines.
Keep the artifacts useful. The geometric failures are the hardest to read from a log line, so attach a screenshot on failure for the right-to-left projects; a reviewer looking at an Arabic button with its icon on the wrong side understands the defect immediately. Direction correctness and label translation fail independently, so keep them as separate specs — the translation-coverage side is covered in the guide on testing internationalized labels, and injecting both into the same run makes triage slower without catching anything extra.
Common Pitfalls
- Setting
dir="rtl"on a layout wrapper instead of<html>, which leaves dialogs, portals, and the scroll container in the document’s left-to-right default. - Relying on
direction: rtlin CSS, which mirrors the pixels while leavingElement.dir,:dir(), and<bdi>auto-detection reading the wrong value. - Comparing
dirattributes rather than resolved direction in the custom check, which flags every correctly inheriting descendant as a violation. - Using physical margins and paddings on mirror-critical components, so the icon, the caret, and the focus ring end up on the geometric side the design never intended.
- Omitting bidi isolation around embedded Latin and numeric runs, which reorders order references, version strings, and phone numbers in both the rendering and the accessible name.
- Leaving
U+2066andU+2069isolate controls in strings that a later assertion compares byte-for-byte against a catalogue value.
FAQ
Is a missing dir attribute actually a WCAG failure, or just a defect?
It fails SC 1.3.2 (Meaningful Sequence) whenever the resulting sequence changes meaning, which is the normal outcome for a right-to-left page: adjacent controls, breadcrumbs, and inline runs are presented in an order the content does not support. It is not a failure of SC 3.1.1, which only requires a valid lang. Reporting it as serious rather than critical reflects that the harm depends on the content, but the fix is a single attribute and there is no reason to defer it.
Why assert geometry instead of just checking the stylesheet for physical properties?
Static analysis of stylesheets misses everything computed at runtime: inline styles, CSS custom properties resolved per theme, and utility classes generated at build time. The runtime comparison of margin-inline-start against margin-left in a right-to-left document reports the used value, so it catches the offender regardless of where the declaration came from, and the icon-position assertion catches mirroring failures that come from flex ordering rather than from margins at all.
Can this custom check live alongside the standard language rules without conflict?
Yes, because it registers a new check and a new rule id rather than reconfiguring html-has-lang or valid-lang. The selector overlaps those rules, so an element with an invalid lang will produce findings from both, which is correct — the language value and the direction agreement are separate defects. Wiring a custom rule into an existing scan is the same registration path described in the guide on integrating axe-core Playwright into an existing project.
Related
- Internationalization & Localization Testing — the parent guide on the locale matrix and per-locale CI checks.
- Testing Internationalized Labels in Automated a11y Workflows — the translation-coverage side of the same locale run.
- Custom Rule Development & Context-Aware Testing — the section covering custom axe rules and the contexts they run in.