RTL and Right-to-Left Layout Testing in Playwright
Most teams build and test their app in a single left-to-right language, then ship it to a market that reads Arabic, Hebrew, Persian, or Urdu — and discover the layout is quietly broken: icons point the wrong way, padding lands on the wrong side, and the cart total drifts off screen. Playwright RTL layout testing lets you catch those bugs in CI instead of in a support ticket. In this guide you will learn how to drive a page into right-to-left mode, assert real directionality with computed styles and bounding boxes, snapshot RTL visuals deterministically, and structure a suite that runs every test in both directions.
🎠Want to master this with real projects? Join the Playwright Automation Mastery course at The Testing Academy.
Contents
Why RTL Layout Bugs Slip Through Normal Tests
Right-to-left (RTL) languages flip the entire visual axis of a page. The browser mirrors block flow, text alignment, scrollbars, and any layout built with logical properties — but it does not automatically fix CSS you wrote with physical properties like margin-left or left: 0. The result is a class of bugs that are invisible in your default locale: a close button glued to the wrong corner, a chevron pointing away from the content it expands, or a progress bar that fills backwards.
Functional tests rarely catch these. Clicking a button by its accessible name works identically in both directions, so getByRole stays green while the pixels are wrong. RTL defects are fundamentally about position and direction, which means your assertions have to look at computed CSS, bounding boxes, and rendered screenshots — not just text. That is exactly where Playwright shines.
| Symptom in RTL | Usual root cause | How to assert it in Playwright |
|---|---|---|
| Element pinned to wrong side | left/right instead of inset-inline | Compare boundingBox() x-position |
| Icon/arrow not mirrored | No transform: scaleX(-1) or RTL asset | Visual snapshot per direction |
| Text aligned left in RTL | Hard-coded text-align: left | Read computed text-align |
| Wrong padding/margin side | padding-left vs padding-inline-start | Read computed style, compare sides |
| Whole page still LTR | Missing dir="rtl" on <html> | Assert document.dir / :dir(rtl) |
Putting a Page Into Right-to-Left Mode
There is no single “RTL switch” in Playwright, because direction is a property of the page, not the browser. You have three realistic levers, and which one you use depends on how your app decides its direction.
- Locale-driven apps read
navigator.languageor theAccept-Languageheader and pick a direction. SetlocaleandextraHTTPHeaderson the browser context. - URL/cookie-driven apps expose
?lang=aror store a preference. Navigate to the localized route or seed a cookie viacontext.addCookies. - For pure layout checks, force the
dirattribute on the document withpage.evaluateso you can test mirroring without depending on translations being ready.
Start by setting a real RTL locale and language header at the context level. This is the closest to what a genuine Arabic-speaking user sends, and it lets your i18n framework choose the direction the same way it would in production.
import { test, expect } from '@playwright/test';
test('app renders in RTL for an Arabic locale', async ({ browser }) => {
const context = await browser.newContext({
locale: 'ar-EG',
extraHTTPHeaders: { 'Accept-Language': 'ar-EG,ar;q=0.9' },
});
const page = await context.newPage();
await page.goto('https://scrolltest.com/');
// The framework should have flipped the document direction.
await expect(page.locator('html')).toHaveAttribute('dir', 'rtl');
await context.close();
});
If your app keys off a URL or a cookie instead of the locale header, drive it that way. Seeding the cookie before the first navigation guarantees the very first paint is already in RTL, which avoids a flash of LTR content that could pollute a screenshot.
test('locale cookie forces RTL on first paint', async ({ browser }) => {
const context = await browser.newContext();
await context.addCookies([{
name: 'lang',
value: 'he',
domain: 'scrolltest.com',
path: '/',
}]);
const page = await context.newPage();
await page.goto('https://scrolltest.com/dashboard');
await expect(page.locator('html')).toHaveAttribute('dir', 'rtl');
await context.close();
});
When you only want to verify that your CSS mirrors correctly — independent of whether translations exist — force the direction directly. This is the fastest way to write a layout regression test against an existing English page.
test('layout mirrors when direction is forced to rtl', async ({ page }) => {
await page.goto('https://scrolltest.com/');
await page.evaluate(() => document.documentElement.setAttribute('dir', 'rtl'));
// Confirm the browser actually applied the RTL direction.
const dir = await page.evaluate(() => document.documentElement.dir);
expect(dir).toBe('rtl');
});
Asserting Real Directionality, Not Just the dir Attribute
Checking that <html dir="rtl"> exists proves the page requested RTL, not that it rendered correctly. The high-value assertions inspect what the browser actually computed. Playwright can read any resolved CSS property through evaluate and getComputedStyle, which is the single most useful tool for directional testing.
test('heading aligns to the right edge in RTL', async ({ page }) => {
await page.goto('https://scrolltest.com/');
await page.evaluate(() => document.documentElement.setAttribute('dir', 'rtl'));
const heading = page.getByRole('heading', { level: 1 });
const textAlign = await heading.evaluate(
(el) => getComputedStyle(el).textAlign,
);
// 'start' resolves to the right edge under RTL; explicit 'right' also passes.
expect(['right', 'start', 'end']).toContain(textAlign);
// The computed direction on the element itself must be rtl.
const direction = await heading.evaluate(
(el) => getComputedStyle(el).direction,
);
expect(direction).toBe('rtl');
});
The strongest directional assertion compares geometry. If a close button is supposed to sit in the inline-start corner, it must move from the left side in LTR to the right side in RTL. Bounding boxes let you assert that flip numerically, which catches hard-coded left values that an attribute check would miss entirely.
test('close button moves to the opposite corner in RTL', async ({ page }) => {
await page.goto('https://scrolltest.com/modal-demo');
await page.getByRole('button', { name: 'Open dialog' }).click();
const dialog = page.getByRole('dialog');
const closeBtn = dialog.getByRole('button', { name: 'Close' });
const dialogBoxLtr = await dialog.boundingBox();
const closeBoxLtr = await closeBtn.boundingBox();
// In LTR the close button hugs the right side of the dialog.
expect(closeBoxLtr!.x).toBeGreaterThan(dialogBoxLtr!.x + dialogBoxLtr!.width / 2);
// Flip to RTL and re-measure.
await page.evaluate(() => document.documentElement.setAttribute('dir', 'rtl'));
const dialogBoxRtl = await dialog.boundingBox();
const closeBoxRtl = await closeBtn.boundingBox();
// Now it should hug the left side.
expect(closeBoxRtl!.x).toBeLessThan(dialogBoxRtl!.x + dialogBoxRtl!.width / 2);
});
When several directional properties matter on the same component, group them with expect.soft so one failure does not hide the others. A single run then reports every side that is wrong, which is far faster to debug than fixing and re-running one assertion at a time.
test('card uses logical spacing in RTL', async ({ page }) => {
await page.goto('https://scrolltest.com/pricing');
await page.evaluate(() => document.documentElement.setAttribute('dir', 'rtl'));
const badge = page.locator('.price-card .badge').first();
const styles = await badge.evaluate((el) => {
const cs = getComputedStyle(el);
return {
paddingLeft: cs.paddingLeft,
paddingRight: cs.paddingRight,
direction: cs.direction,
};
});
// With padding-inline-start, the start padding lands on the RIGHT in RTL,
// so paddingRight should be the larger value here.
expect.soft(styles.direction).toBe('rtl');
expect.soft(parseFloat(styles.paddingRight)).toBeGreaterThan(
parseFloat(styles.paddingLeft),
);
});
Visual Regression Snapshots for Mirrored Layouts
Some RTL bugs are purely visual: an arrow icon that should mirror but does not, or an overlapping label that only a human eye would notice. For these, Playwright’s built-in toHaveScreenshot assertion is the right tool. The key is to capture a separate baseline per direction so the LTR and RTL snapshots never overwrite each other.
import { test, expect } from '@playwright/test';
for (const dir of ['ltr', 'rtl'] as const) {
test(`navbar visual baseline (${dir})`, async ({ page }) => {
await page.goto('https://scrolltest.com/');
await page.evaluate((d) => document.documentElement.setAttribute('dir', d), dir);
// Freeze anything that animates so the snapshot is deterministic.
await page.addStyleTag({
content: `*, *::before, *::after { animation: none !important; transition: none !important; }`,
});
await expect(page.getByRole('banner')).toHaveScreenshot(`navbar-${dir}.png`);
});
}
Because the snapshot filename embeds the direction, the first run writes navbar-ltr.png and navbar-rtl.png side by side. Generate or refresh both baselines with the update flag, then review the RTL image by eye the first time — it may already contain the bug you are trying to prevent.
npx playwright test navbar.spec.ts --update-snapshots
One caveat worth knowing: a font that lacks Arabic or Hebrew glyphs renders tofu boxes that differ between machines and make snapshots flaky. Run visual RTL tests in the official Playwright Docker image, or install the proper script fonts, so the baseline is reproducible across local and CI runs.
Running Every Test in Both Directions With Projects
Hand-writing a forced-direction line in every test does not scale. The clean approach is a Playwright project per direction, plus a tiny fixture that applies the direction automatically on each navigation. Define two projects in your config — one LTR, one RTL — and pass the desired direction through project metadata.
// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
projects: [
{
name: 'ltr',
use: { locale: 'en-US' },
metadata: { dir: 'ltr' },
},
{
name: 'rtl',
use: {
locale: 'ar-EG',
extraHTTPHeaders: { 'Accept-Language': 'ar-EG,ar;q=0.9' },
},
metadata: { dir: 'rtl' },
},
],
});
Now extend the base test with a fixture that reads the project’s direction and forces it after each goto. Wrapping page.goto means every test in the RTL project automatically renders right-to-left without a single extra line in the test body.
// fixtures.ts
import { test as base, expect } from '@playwright/test';
type DirFixtures = { dir: 'ltr' | 'rtl' };
export const test = base.extend<DirFixtures>({
dir: async ({}, use, testInfo) => {
const dir = (testInfo.project.metadata.dir as 'ltr' | 'rtl') ?? 'ltr';
await use(dir);
},
page: async ({ page, dir }, use) => {
const originalGoto = page.goto.bind(page);
// Force the direction immediately after every navigation.
page.goto = (async (url: string, opts?: Parameters<typeof originalGoto>[1]) => {
const response = await originalGoto(url, opts);
await page.evaluate(
(d) => document.documentElement.setAttribute('dir', d),
dir,
);
return response;
}) as typeof page.goto;
await use(page);
},
});
export { expect };
With the fixture in place, a test simply imports test from fixtures.ts and runs in whichever direction the active project specifies. Run a single direction with --project=rtl, or run both in one command and let Playwright report them as separate lines.
// checkout.spec.ts
import { test, expect } from './fixtures';
test('checkout summary stays inside the viewport', async ({ page, dir }) => {
await page.goto('https://scrolltest.com/checkout');
const summary = page.getByRole('region', { name: /summary/i });
const box = await summary.boundingBox();
const viewport = page.viewportSize()!;
// In RTL a hard-coded left offset often pushes content off the right edge.
expect(box!.x).toBeGreaterThanOrEqual(0);
expect(box!.x + box!.width).toBeLessThanOrEqual(viewport.width);
// dir is available if a test needs direction-specific expectations.
expect(['ltr', 'rtl']).toContain(dir);
});
# Run only the RTL project, or run both directions together.
npx playwright test --project=rtl
npx playwright test --project=ltr --project=rtl
Testing bidirectional (BiDi) text correctness
RTL pages frequently embed LTR runs — phone numbers, URLs, product SKUs, prices. These should stay readable and not reorder. You can guard the most common case by asserting that mixed content keeps its expected visual order, and that inputs receive the right dir handling.
import { test, expect } from './fixtures';
test('phone number keeps LTR order inside RTL text', async ({ page }) => {
await page.goto('https://scrolltest.com/contact');
const phone = page.getByTestId('support-phone');
// The visible string must remain in LTR digit order, not reversed.
await expect(phone).toHaveText('+1 800 555 0199');
// An input holding Latin/numeric data should be marked dir="ltr"
// even on an RTL page so the caret behaves correctly.
const cardField = page.getByLabel('Card number');
await expect(cardField).toHaveAttribute('dir', 'ltr');
});
🚀 Level Up Your Playwright
From locators to CI pipelines — build a production-grade Playwright + TypeScript framework step by step.
Best Practices for Playwright RTL Layout Testing
A maintainable Playwright RTL layout testing strategy is less about clever assertions and more about consistency. Keep these principles in mind as the suite grows:
- Prefer logical assertions over physical ones. Test that the inline-start side behaves correctly in both directions rather than hard-coding “left”, so the same test validates both layouts.
- Run the full functional suite in both projects, but keep visual baselines per direction. Functional behavior should be identical; only the pixels differ.
- Seed the direction before the first paint. Cookies, locale, or headers set on the context avoid an LTR flash that corrupts screenshots.
- Pin fonts and disable animation for visual tests so RTL snapshots are reproducible across machines and CI.
- Do not over-assert exact pixel positions. Compare which half of the container an element sits in, not its precise
x, so the test survives copy changes and minor restyles. - Treat the first RTL baseline as a review checkpoint, not an automatic pass — the initial render is exactly where pre-existing mirroring bugs hide.
Conclusion
Playwright RTL layout testing turns an entire category of invisible internationalization bugs into deterministic, CI-enforced checks. By driving the page into right-to-left mode through locale, cookies, or a forced dir attribute, then asserting real computed styles and bounding-box positions rather than the bare attribute, you verify that your layout truly mirrors. Layer in per-direction visual snapshots for the bugs only an eye can spot, and wrap everything in two Playwright projects with a direction-forcing fixture so every test runs both ways for free. Start with one mirrored component, prove the bounding-box flip, and grow outward — your RTL users will feel the difference, even if they never know your tests existed.
FAQ
How do I force a Playwright page into right-to-left mode?
You have three options depending on how your app decides direction. Set a real RTL locale like ar-EG and an Accept-Language header on the browser context for locale-driven apps, seed a language cookie with context.addCookies for cookie-driven apps, or force it directly with page.evaluate(() => document.documentElement.setAttribute('dir', 'rtl')) when you only need to verify CSS mirroring. The forced-attribute approach is the fastest for pure layout regression tests.
Should I assert the dir attribute or computed styles for RTL tests?
Both, at different levels. Asserting html[dir="rtl"] proves the page requested RTL, but it does not prove the layout rendered correctly. The high-value checks read what the browser actually computed: use locator.evaluate(el => getComputedStyle(el).textAlign) for alignment and direction, and compare boundingBox() x-positions to confirm elements flipped to the opposite side. Computed styles and geometry catch hard-coded physical CSS that an attribute check misses.
How do I keep RTL visual snapshots from being flaky?
Three things make RTL screenshots deterministic. First, give each direction its own baseline filename, such as navbar-rtl.png, so LTR and RTL never overwrite each other. Second, disable animations and transitions with page.addStyleTag before capturing. Third, pin fonts that include Arabic and Hebrew glyphs — ideally by running in the official Playwright Docker image — so missing-glyph “tofu” boxes do not differ between local and CI machines.
🎓 Master Playwright End to End
Join hundreds of SDETs building real automation frameworks. Lifetime access, hands-on projects, and a job-ready portfolio.
