How to Test Your App for RTL Bugs Before Users Find Them
A practical QA playbook for finding right-to-left bugs before launch: browser devtools tricks, Playwright and Cypress patterns, visual regression testing, and screen-reader checks with NVDA and VoiceOver.
Most RTL bugs that ship to production were never actually looked at before they shipped — not because nobody cared, but because the QA pass that would have caught them ran the same English click-through it always runs, on a build that happens to also support dir="rtl", and nobody switched the toggle. The bug is invisible in every test that doesn't specifically go looking for it.
This is a guide to going looking for it. It assumes your layout is already built on logical properties and your icons are already sorted — if not, that's an architecture problem covered in our companion piece on building a design system for LTR and RTL. This post is the QA layer on top: how to force RTL rendering without touching a device's OS language, what to actually check once you're looking at it, how to script the check into CI, and how to verify a screen reader handles Arabic content the way a sighted tester's eyes do.
Key Takeaways
- Placeholder Latin text can't surface RTL bugs, because the Unicode Bidirectional Algorithm only produces ambiguous ordering when a line actually mixes scripts, digits, or punctuation — a bug hunt run on Lorem Ipsum in an RTL container is testing nothing.
- Chromium has a real developer flag for this:
chrome://flags/#force-ui-direction(or--force-ui-direction=rtl) flips the whole browser into RTL without touching the OS or system language — faster than maintaining a second OS locale for every check.- Neither Playwright nor Cypress has a built-in "test in RTL" mode. Playwright's emulation options cover locale and timezone, not text direction — asserting on
diris something your test code has to do explicitly.- Percy has a documented, purpose-built RTL feature: its Storybook addon supports
additionalSnapshotsthat captures an extra RTL-flagged snapshot per story. Chromatic's Modes system has no equivalent built-in RTL mode — you compose one yourself from its general locale/decorator primitives.dir="rtl"controls layout, not pronunciation. Screen readers switch voices off thelangattribute, and per W3C guidance on language of parts, an unmarked language change gets read in the reader's default pronunciation rules — Arabic text withoutlang="ar"can be mispronounced even when it displays correctly.- Automated tools help but don't replace a manual screen-reader pass: Deque's own Automated Accessibility Coverage Report found automation catches 57.38% of issues by volume — a figure the report itself notes is inflated by color-contrast checks, which automation catches over 83% of the time.
Start with content that can actually break
The most common reason an RTL pass finds nothing is that it ran on content that couldn't surface a bug. Lorem Ipsum, a string with no numbers, a form with no email field visible — none of that exercises the part of the rendering pipeline that goes wrong. The Unicode Bidirectional Algorithm exists because, as the spec puts it, "without a clear specification, ambiguities can arise in determining the ordering of the displayed characters when the horizontal direction of the text is not uniform" — and that ambiguity only appears when a line actually mixes directions.
Before opening devtools, build fixture content that's guaranteed to exercise it:
- An Arabic sentence containing an English brand or product name.
- A price or order number in Latin digits inside Arabic text (
الطلب رقم ORD-4471 بقيمة 129.50 د.إ). - An email address or URL embedded in Arabic body copy.
- A long, unbroken Arabic word, to check truncation and
text-overflow. - A user-generated string starting in English and continuing in Arabic — the browser picks a base direction from the first strongly-directional character, not from the page's language.
Every check in the rest of this post is easier to run, and every fix gets a real regression test, once you're testing against content shaped like this instead of a paragraph of lorem ipsum dolor sit amet.
Force RTL without touching your OS
You don't need a second device set to Arabic to check a layout. Chromium has a purpose-built flag: chrome://flags/#force-ui-direction, also usable from the command line as --force-ui-direction=rtl, which switches the browser into RTL without touching the underlying OS or language settings. It flips a genuinely different thing than setting dir="rtl" on your own document — it also flips Chrome's own UI chrome and native form controls, which is useful for catching bugs your CSS never touches. The same thread notes an honest limitation: text rendering looks slightly unnatural under the flag (sentence-final punctuation can land oddly), so use it to judge layout, not to proofread Arabic copy.
For a page-only check, open DevTools, select <html> in the Elements panel, and edit the dir attribute directly — the page re-renders instantly, which makes it fast to flip back and forth while inspecting one component. It works the same way in Firefox's inspector, since it's just DOM attribute editing rather than a special feature:
// Flips only the document's own direction, not the browser chrome
document.documentElement.setAttribute('dir', 'rtl');
document.documentElement.setAttribute('lang', 'ar');
Keep both tricks handy: the Chromium flag catches bugs in things the browser itself renders (scrollbars, native <select> menus, validation bubbles); the attribute edit is faster for iterating on your own components.
The bug hunt: what actually breaks
With real bidi content loaded and the page flipped, work through these roughly in order of how often they recur.
Icons and directional controls. Covered in depth in our design-system piece — the QA version is simple: does every back/forward, pagination and progress control point the way reading order flows, and did every icon that shouldn't flip (play, checkmark, clock, logo) stay untouched?
LTR-only form fields. Email, phone, URL and password inputs are Latin-script data even inside an Arabic page, and a field that inherits dir="rtl" will right-align and, per practitioner guidance on auditing RTL layouts, can make the text cursor jump to the wrong end of the string while typing. Force these back to LTR regardless of context:
input[type="email"],
input[type="tel"],
input[type="url"],
input[type="password"] {
direction: ltr;
text-align: start;
}
Wrong digit set. Most MENA product UI should render numerals as Latin digits (123, not ١٢٣) even in Arabic copy, reserving Arabic-Indic digits for cultural or literary contexts — check that no locale-formatting library silently switched prices, counters or timestamps.
Unisolated mixed-direction strings. Anywhere a variable is interpolated into a translated string — a username, an order ID — check what happens when its script differs from the surrounding sentence. Per MDN's dir documentation, dir="auto" is built for this: it "lets the user agent determine the base direction from the text, generally using the first character with a strong directionality," and is meant "for data with an unknown directionality, like data coming from user input." If a value isn't isolated with <bdi> or dir="auto", watch for punctuation or parentheses landing on the wrong side of a number or name.
Horizontal overflow at the wrong edge. A layout only ever checked in LTR can overflow past the logical-end edge without a scrollbar appearing where expected, or trigger one where it shouldn't. Resize narrower than usual and check both edges.
Native browser chrome. Date pickers, <select> menus and validation tooltips are browser-rendered, not CSS-rendered, so check them specifically under the force-ui-direction flag — a page-level attribute edit won't always flip them.
Automate it: Playwright and Cypress
Neither framework treats RTL as first-class. Playwright's emulation docs cover locale and timezoneId as context options —
const context = await browser.newContext({ locale: 'ar-AE', timezoneId: 'Asia/Dubai' });
— but locale emulation changes navigator.language and Accept-Language, not text direction. If your app derives dir from locale server-side, setting locale is enough to trigger it end-to-end, which is the version worth testing since it's what real users hit:
test('RTL checkout renders logical alignment', async ({ page }) => {
await page.goto('/ar/checkout');
await expect(page.locator('html')).toHaveAttribute('dir', 'rtl');
const total = page.getByTestId('order-total');
await expect(total).toHaveCSS('text-align', 'start'); // computed style, not just a screenshot
});
Cypress has no locale-emulation option at all, so set the attribute directly and assert against the mixed-content fixtures described earlier:
cy.visit('/ar/dashboard');
cy.get('html').should('have.attr', 'dir', 'rtl');
cy.get('[data-testid="user-balance"]').should('contain.text', 'ORD-4471');
The pattern worth committing to CI is the same either way: assert on dir/lang explicitly rather than trusting a route rendered correctly, and run it against bidi fixtures — not clean single-script strings that can't surface the bug even when one exists.
Visual regression: check the tool's actual RTL support
Screenshot diffing suits RTL well because direction bugs are spatial — a margin on the wrong side or an unmirrored icon shows up as a pixel difference. But "supports visual testing" and "supports RTL testing" aren't the same claim.
Percy has a documented feature for this: its Storybook addon accepts additionalSnapshots, capturing a second snapshot with a distinguishing suffix and a direction: 'rtl' query parameter:
// .storybook/percy.js
module.exports = {
additionalSnapshots: [
{ suffix: ' [RTL]', queryParams: { direction: 'rtl' }, include: ['^FormElement: .*'] },
],
};
This only works if your Storybook preview actually reads that query parameter and flips dir on the preview root — the addon captures the extra snapshot, but your own decorator makes the direction change happen.
Chromatic has no equivalent built-in RTL mode. Its Modes documentation describes a general system for varying global Storybook state — viewport, theme, locale — via decorators that "read the global value and provide the necessary context to render a specific mode." Locale is a first-class global there; direction isn't, so you'd define your own RTL mode the same way you'd define a custom theme. Neither approach is wrong — the difference is whether the tool built the RTL wiring for you or expects you to compose it. Check which one you're relying on before assuming either "handles RTL" out of the box, and pair either tool's pixel diff with the explicit attribute assertions above, since a generous similarity threshold can pass a screenshot that's consistently, uniformly wrong.
Screen readers: RTL is layout, not pronunciation
It's easy to assume a page that displays correctly in Arabic will also be heard correctly. dir and lang are separate signals, and only one affects speech. W3C guidance on Language of Parts is direct: marked correctly, "speech synthesizers that support multiple languages will be able to speak the text in the appropriate accent with proper pronunciation"; unmarked, "the synthesizer will try its best to speak the words in the default language it works in," mispronouncing it. A layout that's pixel-perfect in RTL but missing lang="ar" still fails a screen-reader pass.
Two mainstream readers make this concrete to test:
- NVDA ships a setting, per its own user guide, to "switch speech synthesizer languages automatically if the text being read specifies its language" — on the web, that specification is the
langattribute. Load your Arabic page with NVDA and an Arabic-capable synthesizer, and confirm it actually switches voices at each language boundary. - VoiceOver exposes the same idea through its rotor: per Apple's support documentation, turning the rotor to "Language" and flicking up or down switches between languages added in Settings, letting you confirm Arabic and Latin content each get read in the right voice.
Automated tools narrow the gap but don't close it. axe-core and Lighthouse both check for a valid document lang attribute — Deque's rule set documents this explicitly as html-has-lang and valid-lang — catching a forgotten lang entirely, but neither can verify that one interpolated span of Arabic got its own lang="ar", or that a synthesizer has an Arabic voice installed. Deque's Automated Accessibility Coverage Report found automated tools catch 57.38% of accessibility issues by volume across real audited pages — a figure the report itself flags as skewed by color-contrast checks, which automation catches over 83% of the time and which make up nearly a third of all recorded issues. Language and pronunciation bugs aren't in that easy category; budget for an actual NVDA-plus-Firefox or VoiceOver-plus-Safari pass rather than letting a green axe-core report stand in for it.
A practical RTL QA checklist
Run this after any change touching layout, forms, or translated strings — not just before a launch.
- [ ] Loaded the page with real, mixed-direction fixture content — not Lorem Ipsum.
- [ ] Verified
dir="rtl"andlang="ar"are both set, not just one. - [ ] Checked native browser chrome (date pickers,
<select>, validation tooltips) under--force-ui-direction=rtl. - [ ] Confirmed sequence icons (back, forward, pagination, progress) point correctly, and non-directional icons didn't flip.
- [ ] Confirmed email, phone, URL and password fields stay LTR inside an RTL form.
- [ ] Confirmed prices, counters and IDs use the intended digit set.
- [ ] Checked horizontal overflow at both viewport edges at a narrow width.
- [ ] Wrote a Playwright or Cypress test asserting
dir/langand a computed logical-alignment style, not just a screenshot. - [ ] Captured an RTL visual-regression snapshot using the tool's actual documented mechanism, and confirmed the app shell responds to the flag it sends.
- [ ] Ran an actual NVDA or VoiceOver pass and confirmed the voice switches at each language boundary.
None of this needs exotic tooling — a browser flag, an attribute edit, a couple of test assertions, and twenty minutes with a screen reader most teams already have. What it needs is doing it on purpose, on content built to fail if something's wrong, instead of trusting that a build which compiles and renders in English is also correct in the direction nobody on the team reads by default.