How browsers render mixed RTL and LTR text
Follow how browsers turn mixed-direction source text into displayed lines, and learn to diagnose changes caused by context, isolation, and wrapping.
By Omran Khleifat, Founder of Ritla
Guide13 min read
A telemetry notice has one report ID in the database: 621-804. Show the ID alone in an RTL panel and it looks intact. Put it after an Arabic label and its groups appear as 804-621. Resize the panel and the punctuation may seem to move again as the sentence wraps. It is tempting to blame the font, the browser, or the person who wrote the stylesheet. The browser is following a set of rules, and the interesting question is which context you gave it.
This guide follows the rendering path from stored characters to displayed lines. The Unicode Bidirectional Algorithm defines the ordering behavior, while HTML supplies paragraph direction and boundaries around inserted content. The description below is the standard's logical model, not a claim that every browser executes these stages as separate functions in this exact sequence. What matters for debugging is which decision can change the result.
Keep three facts apart from the start: the DOM holds characters in logical order; the text algorithm chooses a visual order for directional runs; CSS lays out the boxes that contain those runs. A wrong box position and a wrong number order can share a screenshot. They do not share a fix.
Start with the characters the browser received#
The browser does not begin with a screenshot. It begins with the characters in the document and the structure around them. In a weather-balloon operations page, a notice might be written as:
<p class="telemetry-notice" lang="ar" dir="rtl">
وصل تقرير البالون 621-804 من المحطة.
</p>The numeric source remains 621-804. The DOM does not replace it with 804-621 when the paragraph is drawn. textContent, selection, and copying preserve the logical order. W3C's logical-versus-visual explanation is useful whenever a bug report says the browser “changed the data.” It may have changed the display, which is serious enough, but you should verify the stored value before filing a data-corruption incident.
Inspect the actual DOM in the failing state. A static template may contain one value, while the runtime inserts a formatted value with different separators or an extra space. Those are different bidi inputs. Do not debug a simplified string in a blank test page and assume it proves how the production sentence renders. Surrounding Arabic letters can change the outcome. The algorithm is allowed to notice its surroundings; it has not signed a contract to treat your fixture as an island.
There is another input hidden in plain sight: the boundary between elements. A value inserted as a plain text node shares its paragraph's bidi context. A value inside an element with its own dir gets a directional boundary. A styling-only <span> without dir does not provide the same isolation. The HTML tree therefore matters even when the visible characters are identical.
Establish the paragraph's base direction#
For known Arabic page content, set language and direction on the document:
<html lang="ar" dir="rtl">The HTML dir attribute sets base direction and generally passes it to descendants that do not declare another direction. lang="ar" identifies the language but does not make text RTL. In the Chromium 151 behavior checked for this guide, a page with only lang="ar" computes to LTR. concerns document direction that is missing, contradicted, or declared only in CSS. Set the document's direction once when it is known; the HTML dir guide handles exceptions and page-level markup in more detail.
In Unicode's model, an RTL paragraph starts from an odd base embedding level, while an LTR paragraph starts from an even one. That base does not force every character to march in the same direction. It supplies the context against which Arabic, Latin, numeric, and neutral runs are resolved. A Latin word can stay readable from left to right inside the RTL paragraph. A space or hyphen has less independence and may take its direction from nearby runs or the paragraph level.
Do not confuse dir="auto" with automatic language detection. HTML chooses direction from the first strongly directional character of the relevant element. If you put dir="auto" on the whole telemetry notice, the first Arabic letter makes the notice RTL; the numeric ID does not acquire a separate direction. For unknown user-generated text, dir="auto" can be useful on the user value itself. For a known Arabic notice and a known fixed-order ID, explicit directions are clearer.
The base direction is also not text-align. text-align places inline content within a box; it does not determine the bidi order of characters inside that content. Moving the line to the right edge may make a screenshot look more Arabic while leaving the ID's groups in the wrong order.
Resolve strong, weak, and neutral characters#
Now the browser has to decide how the characters behave within that RTL paragraph. Arabic letters have a strong Arabic right-to-left type. Latin letters have a strong left-to-right type. Western digits are weak numeric characters. A hyphen is a separator whose effect depends on its neighboring types. Spaces and much other punctuation are neutral. The Unicode character-type table gives the complete classification; the small set here is enough to explain the telemetry notice.
In the notice, Arabic letters precede 621-804. Under the UBA's W2 rule, Western digits after the relevant Arabic strong type are treated as Arabic-number types for ordering. Their glyphs remain Western digits; the rule is not converting 6 to another numeral. The hyphen does not glue those resulting numeric groups into one LTR run, so the checked Chromium display puts the groups in the visual sequence 804-621 when described from left to right.
Compare a separate paragraph containing only the same ID:
<p dir="rtl">621-804</p>With no Arabic letter before the ID in that paragraph, the checked display keeps the groups as 621-804. Nothing in the database changed. The preceding strong text changed, and the weak characters followed a different route through the rules. This is the browser behavior that makes an isolated test cell an unreliable stand-in for an inline notification.
Not every separator behaves like a hyphen. The operations page may also show a review time:
<p lang="ar" dir="rtl">موعد المراجعة 16:25</p>In the checked Chromium behavior, the numeric time remains 16:25 in its written order after Arabic text. The colon is a different bidi separator from the hyphen and can keep the number together in this context. Do not conclude that the browser either reverses all numbers or preserves all numbers. The exact characters around the digits matter. This is why a test fixture should use the product's real format, not whichever string was easiest to type.
For the full mechanics, including the weak and neutral resolution rules, see the bidi developer guide and the Unicode specification. The debugging point here is to identify which character in the visible value is doing the surprising work. In our notice, it is not an Arabic letter. It is the hyphen between two Western-digit groups after Arabic context.
Isolation changes which neighbors can influence the value#
The report ID is a fixed-order key. An operator must be able to read the same group order that appears in logs and support records. Give that value its own LTR boundary:
Problem
<p class="telemetry-notice" lang="ar" dir="rtl">وصل تقرير البالون 621-804 من المحطة.</p>Better
<p class="telemetry-notice" lang="ar" dir="rtl">وصل تقرير البالون <bdi class="report-id" dir="ltr">621-804</bdi> من المحطة.</p>The source string, Arabic sentence, and paragraph direction stay the same. <bdi> makes the identifier an isolated unit, and dir="ltr" states its known reading direction. In the checked Chromium behavior, its groups display as 621-804 within the RTL sentence. The surrounding sentence remains RTL. This is not a request to reverse the digits; it prevents the surrounding Arabic text from deciding the identifier's internal visual order.
A plain <span class="report-id"> would not create that boundary. A <span dir="ltr"> would, because the dir attribute on an HTML element supplies direction and isolation. If an appropriate semantic element already wraps the value, use it rather than adding a nested element solely for the privilege of nesting. W3C's inline bidi guidance explains the role of tightly wrapped opposite-direction phrases.
There is a limit: an Arabic-language range written with a hyphen may intentionally read in the correct sequence from right to left even when its groups appear reversed to someone scanning left to right. Isolating every range just because it looks different in an English screenshot can change how an Arabic reader understands it. The report ID has a fixed group order, so isolation is justified. The browser cannot know whether your digits denote a range or an identifier. You have to know what the field means.
Shape, measure, break, then reorder each line#
The bidi rules do not end with character types. The standard's reordering phase describes a logical sequence: resolve levels for the paragraph, shape characters into glyphs, use their widths to determine line breaks, then reorder the resolved runs on each displayed line. An engine can implement that more efficiently, but its output must respect the resulting ordering rules. The important detail is that line breaking happens before the final per-line visual reordering. The browser does not restart the entire paragraph-level algorithm independently on every line.
That explains a class of bugs that only appears at narrow width. A phrase that fits on one line can be split across two. Neutral punctuation at the end of a line, the placement of a Latin run, and the point where the eye resumes on the next line deserve inspection in that exact rendered state. Do not assume a mobile media query changed direction merely because resizing exposed the symptom. Inspect the winning CSS rule, but also check whether the only change was a line wrap.
A soft wrap is not a new paragraph. The browser does not take the first strong character on line two and choose a fresh base direction for that line. The paragraph's directional processing has already supplied levels for its runs. After wrapping, the algorithm resets trailing whitespace on each line to the paragraph level, then reorders the resolved runs within that line. That detail explains why a neutral character near a line edge may look newly attached to a neighboring run even when the DOM and the paragraph's dir have not changed. It also tells you what not to fix: adding dir="ltr" to a whole mobile-only paragraph merely because a period shifted at a wrap changes the base context for every Arabic word in it.
To isolate the variable, keep the text, DOM structure, and font constant while changing only the available inline width. Note the first width at which the line breaks, then compare the rendered line immediately above and below that width. Next, keep the width constant and test the value with its own directional boundary. If the boundary repairs a fixed-order value at both widths, the problem was not a mysterious mobile layout mode. If the text is actually cut off rather than reordered, inspect overflow, white-space, and the container's size instead. A screenshot of missing letters does not tell you which of those happened.
Fonts affect this without being bidi rules. A fallback Arabic font can give the same sentence different glyph widths and change where it wraps. concerns Arabic text falling back past the declared font family, including a webfont that failed to load. If the reported bug appears only during loading, compare the line before and after the intended font arrives. If it appears only in a narrow card, inspect the available width and the wrap. A font substitution, a width constraint, and a bidi boundary can all contribute to one screenshot, which is why “try direction: ltr” is not much of a diagnosis.
The browser may also mirror the glyph used for certain paired punctuation in RTL context. That is a text-rendering decision, not the same as reversing stored characters or flipping an SVG icon. If an icon points against the action it represents, inspect the icon separately. If a bracket appears on the wrong visual side of a mixed phrase, inspect its text context and isolation. Text punctuation and interface artwork share a screen, not an algorithm.
Avoid forcing an override just to get one sample into the expected visual order. unicode-bidi: bidi-override and <bdo> tell the renderer to ignore the characters' ordinary directional behavior within the override. That is a much stronger instruction than isolating a known-direction report ID. It can make the test identifier look convenient while damaging Latin words or punctuation placed in the same wrapper later. An override has specialized uses, but “this sentence looked strange at phone width” is not a specification for one. Use dir and isolation to express a value's boundary, then test the resulting line.
One more distinction saves time: Arabic shaping is not the same as bidi reordering. Shaping chooses context-appropriate glyph forms for Arabic letters; bidi ordering determines where the directional runs appear. A font can change glyph width and therefore the wrap, while the source characters and bidi classes remain the same. Reversing the JavaScript string cannot repair either process. It changes the input and asks the browser to apply its normal rendering rules to the newly damaged text. The browser will oblige.
A form field can supply a different context#
The same value may be shown in a notice and entered in a field. Do not assume those are identical rendering contexts. In the checked Chromium behavior, <input type="tel"> defaults to LTR, while ordinary text inputs inherit an RTL page's direction unless they set dir. A phone number typed into a plain text field can therefore look different from one typed into a telephone field on the same page. The content did not become a different phone number; the control's direction changed. The phone-number guide covers which field semantics and direction to use.
An empty <input dir="auto"> is another case worth testing as a state, not just as a selector. In the checked Chromium behavior, it starts LTR, so an Arabic placeholder is drawn from the left edge. Typing Arabic makes it RTL; typing Latin letters or digits keeps it LTR. If a screenshot shows a placeholder on the “wrong” side but the typed value looks correct, inspect the empty and filled states separately. The first-strong heuristic has no strong character to work with before the user types. This is not evidence that the document-level dir failed.
Form controls also have their own editing and validation behavior. The page's lang="ar" did not change Chromium's built-in required-field message when the browser UI language was English in the checked setup. That is a localization question, not a bidi reordering of your placeholder. The practical lesson is to identify which text the browser generated and which text your DOM supplied before changing direction attributes. A single form screenshot can contain both.
Inspect the rendered line without mistaking it for the DOM#
When a mixed-direction result surprises you, inspect both layers. Select the paragraph in DevTools and check its dir attribute and computed direction. The computed style tells you the element's base CSS direction, not the resolved direction of every character in its text. Next, inspect the DOM text or textContent to confirm what characters were stored. Then look at the actual rendered line. Those three observations answer different questions.
In Chromium DevTools, $0 is the selected element. With the telemetry paragraph selected, you can check:
({
markupDirection: $0.getAttribute("dir"),
computedDirection: getComputedStyle($0).direction,
sourceText: $0.textContent.trim()
});If sourceText is already wrong, fix the data or formatting stage. If the source is right but the computed direction is wrong, trace the document and component dir values and any CSS override. If both are right but the visual ID is wrong, inspect whether the ID has its own boundary. Do not run the snippet, see the correct source ID, and declare victory. Copying text yields the same logical order and likewise cannot certify the visual result.
For a long inline value that may itself span lines, getClientRects() can show the element's per-line fragments. With the “Better” HTML above rendered in the page:
const id = document.querySelector(".telemetry-notice .report-id");
[...id.getClientRects()].map(({ left, right, top }) => ({ left, right, top }));The short report ID may produce one rectangle. A longer inline run that wraps can produce more. This tells you where the browser placed fragments, not whether their letters are in the correct visual order. It is a geometry aid, not a replacement for reading the line. If a text box is clipped by overflow: hidden, that is yet another issue: concerns content wider than its box with hidden overflow and no ellipsis. Do not call missing text a bidi reversal.
Test the context that ships#
Keep three fixtures for the same report ID: alone in an RTL paragraph, after Arabic text without isolation, and after Arabic text inside an explicitly LTR isolate. In the checked Chromium behavior, their numeric displays are 621-804, 804-621, and 621-804 respectively. That small matrix proves both that the context matters and that the markup repair changes only the intended value. Test it in the browsers your product supports; the brief's measured behavior did not cover Firefox or Safari.
Then test the notice at a wide width and a narrow width where it wraps. Preserve the exact punctuation and localized words. A screenshot of a detached 621-804 fixture will not catch the production behavior after an Arabic label. A test that reads only textContent will pass all three cases because all three store the same ID. Use a visual review or an assertion suited to what a reader actually sees, and ask someone who reads Arabic to confirm whether the sentence's order makes sense. The browser can follow the spec perfectly while the UI expresses the wrong field boundary.
Do not turn this into a rule that every Latin or numeric run is defective. A Latin word often reads correctly without extra markup, and a time with a colon can stay intact. Focus testing on values with punctuation, leading signs, spaced groups, and neighboring text that can change the run. concerns unisolated phone numbers, email addresses, and Latin tokens inside Arabic text; it does not claim to detect every numeric report ID. concerns punctuation at the wrong visual end of Arabic text. Keep a manual fixture for the report ID even if a scan reports neither issue.
A free Ritla scan can surface the mixed-text hazards those checks describe, giving you real sentences to inspect. For this report ID, the decisive evidence is the three-context visual test: same stored characters, different surroundings, and one explicit boundary that restores the fixed order. Once you see that, the browser's behavior is less mysterious. Still occasionally inconvenient, but less mysterious.
Checks in this guide
- document direction missing, contradicted, or declared only in CSS
- Arabic text falling back past the declared font family (measured, declared, or a webfont that failed to load)
- Clipped text: content wider than its box with overflow hidden and no ellipsis
- Unisolated bidi hazards: phones, emails, Latin tokens inside Arabic text
- Punctuation rendering at the wrong visual end of Arabic text