Bidirectional text (bidi): developer guide
Learn how browsers order mixed Arabic, Latin text, numbers and punctuation, then fix bidi bugs with narrow semantic boundaries and repeatable tests.
Guide14 min read
An Arabic sentence can contain a phone number, email address, Latin product name, price and order ID without changing how any of those values are stored. The browser displays the combined text by resolving directional runs. A hyphenated ID can appear with its numeric groups swapped, a phone can put the plus sign at the opposite end, and a product name can pull a nearby count into its LTR run.
These are not string-reversal problems. Reordering the source usually makes the data wrong. The fix is to give the browser the right base direction and isolate each inserted value at its semantic boundary.
Bidi has logical order and visual order#
Text in the DOM has a logical order: the sequence of characters that was authored, copied, submitted and usually exposed through the accessibility tree. The browser computes a visual order for display.
The Unicode Bidirectional Algorithm assigns directional behavior to characters, resolves runs and places those runs inside a paragraph whose base direction is LTR or RTL. Arabic letters form strong RTL text. Latin letters form strong LTR text. Digits have numeric behavior. Spaces and much punctuation are neutral, so their position depends on nearby resolved text.
This is why “reverse the string” is the wrong repair. It changes logical data to compensate for one rendering context. The same value can then fail when copied, submitted, displayed alone or placed in another sentence.
A useful debugging model has three layers:
- Logical data: the exact character sequence.
- Directional boundaries: paragraph direction and isolated values.
- Visual result: the order a user sees in this context.
Inspect all three. A correct database value does not prove a correct display, and a plausible screenshot does not prove the source is intact.
Set the paragraph's base direction first#
For an Arabic page, establish document language and direction at the root:
<html lang="ar" dir="rtl">The dir attribute supplies the base direction inherited by ordinary descendants. lang="ar" identifies language but does not set direction. In Chromium 151, measured on 2026-09-17, a page with Arabic language and no dir still laid out LTR. Firefox and Safari were not measured for the browser-specific results in this guide, so repeat the fixtures in the supported matrix.
Base direction answers: where does this paragraph begin, and how should neutral characters be resolved when stronger context does not settle them? It does not force every descendant string to RTL. Latin text and numbers still form their own runs inside the paragraph.
Use a local dir="ltr" when a complete region has a known LTR base, such as an English quotation or a code block. Do not switch a whole card to LTR to repair one phone number. That gives the Arabic label and punctuation the wrong context.
Character types explain the surprising cases#
You do not need to memorize every Unicode rule, but three categories explain most product bugs:
- Strong characters establish an RTL or LTR run. Arabic letters and Latin letters are strong in opposite directions.
- Weak characters include digits and separators whose behavior can change from context.
- Neutral characters include spaces and much punctuation. They are resolved from surrounding runs and paragraph direction.
The browser is not trying to preserve business identifiers. It sees character classes. A hyphen in an order number does not announce “keep these groups together.” A plus sign does not announce “I belong at the start of this phone.” Markup has to express the value boundary.
Much of what first looks reversed is correct RTL reading order. Define the expected result before filing a bug. For an ID or phone, the product usually expects the authored group order to remain visually intact. For an Arabic sentence, the line should begin at the right and punctuation should follow Arabic reading conventions.
Hyphenated numeric IDs expose missing isolation#
This is a useful test because it fails in a way a Latin-prefixed identifier may not.
Problem
<p dir="rtl">رقم الطلب 2026-0042</p>In Chromium 151, the visible numeric groups appeared as 0042-2026. Under the algorithm's W2 rule, digits after Arabic text are treated as Arabic numbers. Slashes, colons, commas and full stops keep measured sequences such as 12/05/2026, 10:30 and 1,499 together. A hyphen does not, so 10-20 appeared as 20-10 in the same context.
With no Arabic before them, the measured numeric values displayed as authored. Context changes the result, which is why testing a value by itself misses the defect.
Isolate the ID:
<p dir="rtl">
رقم الطلب <bdi>2026-0042</bdi>
</p>When the data contract defines the value as LTR, an explicit boundary is also suitable:
<p dir="rtl">
رقم الطلب <span dir="ltr">2026-0042</span>
</p>A plain <span> changes nothing because it creates no bidi boundary. R021 reports unisolated phones, emails and Latin tokens inside Arabic text.
A Latin prefix changes the run#
Not every hyphenated identifier fails. In the measured context, IDs beginning with strong Latin letters stayed intact:
INV-2026-0042
ABC-123
AB-12-34An identifier that began with digits and ended with letters behaved differently. 12-AB appeared as AB-12, with or without preceding Arabic text.
This distinction matters for test fixtures. ABC-123 is a poor reproduction for a bug that affects numeric-leading identifiers. Include each identifier family the product actually uses:
- digits and hyphens;
- Latin prefix plus digits;
- digits followed by Latin suffix;
- slashes or colons;
- punctuation immediately after the value.
Do not add isolation only to values that happen to fail today. Apply it at the component boundary for the semantic type, such as every order ID. A future format change should not reactivate the bug.
Phone numbers and product names need boundaries#
Spaces behave like hyphens here: they split digit groups instead of holding them together. An unisolated phone number after an Arabic label therefore shows its groups in reverse visual order, with the plus sign moved to the far end. Phone numbers carry enough further traps, in links, inputs and Arabic-Indic digits, to have their own guide. The rule that matters here is the same as for IDs: wrap the value only. Do not include the Arabic label inside the same auto-direction wrapper, because its first strong letter makes the wrapper RTL.
Latin product text can contain neutral punctuation too. In Chromium 151, unisolated C++ appeared as ++C inside the RTL paragraph. <bdi>, dir="ltr" or dir="auto" around that value kept it in order.
Treat a product name as one inserted value, even when it contains spaces, symbols or a version number. Splitting it across wrappers gives the algorithm more boundaries to resolve and can separate punctuation from the name.
A Latin name can capture a following number#
Dynamic strings can affect their neighbors even when each fragment looks harmless.
<p dir="rtl">شاركت Nour 5 ملفات</p>In Chromium 151, the 5 joined the Latin name's LTR run. It appeared between the Arabic verb and Nour, away from the Arabic word it counted.
Isolate the inserted name:
<p dir="rtl">شاركت <bdi>Nour</bdi> 5 ملفات</p>The key is not that Latin names are dangerous. The boundary is unknown user data embedded next to another dynamic value. Test Arabic, Latin and digit-leading names. Add punctuation and counts on both sides.
Do not wrap the full localized sentence in one dir="auto" element. It contains multiple semantic values and a known Arabic message direction. Isolate each unknown insertion instead.
Percent signs, minus signs and sentence punctuation#
Neutral and weak characters do not all resolve alike. In the measured browser:
50%after Arabic text appeared as%50;50%on its own appeared as authored;-5showed the minus sign on the right in both tested contexts;- an English sentence inside an RTL paragraph placed its final full stop at the left end, so
Save changes.appeared as.Save changes.
These displays are not automatically defects. The product needs an approved formatting and language rule. A percentage may deliberately use a local convention. A full English sentence embedded in Arabic should normally have its own language and direction boundary:
<p dir="rtl">
<span lang="en" dir="ltr">Save changes.</span>
</p>R024 reports punctuation at the wrong visual end of Arabic text. Verify the expected reading with a fluent reviewer before changing sentence punctuation. For machine values, compare against the written data contract.
Do not fix sign placement by moving characters in the source. Format the value once, isolate the complete result and test it at the beginning, middle and end of an Arabic sentence.
bdi, dir="auto" and explicit direction#
<bdi> isolates its content from surrounding bidi context and derives the content's direction. An ordinary element with dir="auto" also derives its direction and isolates its contents. The W3C inline bidi guidance recommends adding dir="auto" when an existing semantic element already wraps the value, or using <bdi> when no such element exists.
Use them by intent:
| Content | Markup choice |
|---|---|
| Known Arabic paragraph | dir="rtl" on its container or inherited root |
| Known LTR phone, email, URL or code | dir="ltr" on the value |
| Unknown user name or comment | <bdi> or a narrow isolated auto-direction boundary |
| Fixed-format value requiring forced order | <bdo> only when normal bidi resolution is unsuitable |
In Chromium 151, both <bdi> and dir="auto" took direction from the first letter inside. Values containing digits and punctuation but no letters, including the measured phone and numeric ID, resolved LTR. An Arabic label inside the same element became the first strong text and made the whole wrapper RTL. Keep labels outside.
An empty <input dir="auto"> resolved LTR in the measured browser. Its Arabic placeholder appeared at the left edge. Typing an Arabic letter changed the field to RTL, while Latin letters or digits kept it LTR. That direction jump may be correct for a general chat input and distracting for a field that only accepts Arabic addresses.
Use auto-direction only when the content is genuinely unknown. Known data types are easier to test when their direction is explicit.
Do not assemble localized sentences from directional fragments#
String concatenation creates bidi problems and translation problems at the same time. Word order differs between languages, and the browser cannot infer which fragments form one semantic value.
A pattern such as this is fragile:
const message = arabicPrefix + userName + " " + count + arabicSuffix;The translator cannot move the placeholders, and the renderer receives one flat string with no isolation around userName. If that name begins with Latin text, a following count can join its LTR run.
Keep the full sentence in the localization resource with named placeholders. At render time, interpolate each unknown or known-LTR value through a component that creates the right boundary:
<p dir="rtl">
شاركت <bdi>Nour</bdi> 5 ملفات
</p>The Arabic words and placeholder order belong to the translation. The <bdi> belongs to the value renderer. This separation lets a translator change the sentence structure without removing the bidi protection.
Do not isolate every placeholder automatically. A localized Arabic noun inserted into an Arabic sentence may need no boundary, while a user name, email, phone or product code usually does. Define placeholder types in the message contract so rendering code knows which data is unknown, LTR or already localized.
Plural and select messages need the same review. Test every branch because one branch may place the same value next to punctuation or another number. A singular screenshot does not prove the plural path has safe boundaries.
Avoid putting markup characters directly into translation strings unless the localization system has a controlled rich-text model. Translators should place named semantic placeholders, not edit raw tags or invisible directional characters.
bdo is an override, not a general fix#
<bdo> overrides normal bidi ordering in the direction you specify. It is stronger than isolation.
Arabic-Indic digit groups reveal a case where isolation may not preserve the desired authored order. In Chromium 151, spaces between Arabic-Indic digit groups resolved RTL even inside <bdi> or dir="ltr", including in English text. A national ID written ١٠٢٣ ٤٥٦ ٧٨٩ displayed as ٧٨٩ ٤٥٦ ١٠٢٣ inside <bdi>. <bdo dir="ltr"> kept the authored grouping:
<p dir="rtl">
رقم الهوية <bdo dir="ltr">١٠٢٣ ٤٥٦ ٧٨٩</bdo>
</p>Use this only when the value's required visual order is defined and all its contents should be forced. Applying <bdo> to unknown user text bypasses the browser's normal handling and can corrupt a different input.
Do not reach for <bdo> because <bdi> sounds ineffective. First confirm the exact character sequence, digit system, separators and expected market display.
CSS direction and unicode-bidi are lower-level tools#
CSS provides direction and unicode-bidi. They can establish direction, isolation and embedding behavior, but HTML usually provides a clearer semantic boundary for product content.
In Chromium 151, CSS direction: rtl displayed RTL without a dir attribute, but the element did not match :dir(rtl). A component could therefore render text one way while its :dir() selectors chose icons or layout for another direction.
Prefer:
<span dir="ltr">qa@example.com</span>over a class whose only job is to conceal the missing semantic direction:
.force-ltr {
direction: ltr;
}CSS is appropriate when bidi behavior belongs to a reusable presentation pattern, but keep it scoped and documented. Avoid setting direction on every descendant. That destroys inheritance and legitimate nested boundaries.
Directional control characters also exist in Unicode. They are difficult to see in source, code review and debugging. Prefer visible markup boundaries in application UI unless the text format itself requires controls and the team has a process for validating them.
Inspect invisible directional controls in stored text#
Copied text, imported records and older localization files can contain invisible bidi controls. Two values that look identical in an editor may resolve differently because one contains an embedding, mark, isolate or override character.
When markup looks correct but the display still differs between records, print the code points:
function showCodePoints(value) {
return [...value].map((character) => ({
character: JSON.stringify(character),
codePoint: `U+${character.codePointAt(0)
.toString(16)
.toUpperCase()
.padStart(4, "0")}`,
}));
}
console.table(showCodePoints(value));Compare a failing value with a known clean fixture. Record the exact code points in the bug rather than pasting only the visually rendered text.
Do not strip every directional control from all content as a blanket fix. Some text formats use them intentionally, and normalization can alter the author's data. Decide by source and field contract: product-owned labels should usually express direction through markup; imported or user-authored text may need controls preserved, rejected or normalized under a documented policy.
If the product removes a control, test the logical value, visual result, copy behavior and downstream export. A display repair that silently changes signed, legal or machine-readable data needs explicit ownership.
Keep invisible controls out of source-code workarounds when an HTML boundary solves the same problem. Markup is visible in DevTools, reviewable and attached to the value's semantic container.
Form fields need direction by data type#
The form can inherit RTL while individual controls use the direction of their values:
<form lang="ar" dir="rtl">
<label for="gift-message">رسالة الإهداء</label>
<textarea id="gift-message" name="gift-message" dir="auto"></textarea>
<label for="promo">رمز الخصم</label>
<input id="promo" name="promo" dir="ltr" autocomplete="off">
</form>The gift message may be written in Arabic or English, so it takes its direction from what the customer types. The promotion code is always a Latin code such as WELCOME10, so its direction is stated.
In Chromium 151, type="tel" was LTR by default. Email, URL, number, search and text controls inherited the page direction unless they set one. R023 reports text inputs rendered LTR where Arabic input is expected; R070 reports telephone, email or URL inputs rendered RTL.
Test editing, not only the final value. Type, paste, move the caret across punctuation, select a range, delete, undo and submit. Mixed-direction caret movement can feel surprising even when the static display looks correct.
Place prefix and suffix decorations carefully. A currency symbol or country code rendered as a separate flex item is a layout relationship; a sign inside the formatted value participates in bidi ordering. Do not split a value merely to force the screenshot into place.
User-generated content needs one boundary per value#
Unknown names, comments and messages can begin with Arabic, Latin letters or digits. Render each value inside its own isolation boundary. Do not infer direction once at account creation and apply it forever, because a user can enter content in another language later.
<article dir="rtl">
<h3><bdi>Rania Aziz</bdi></h3>
<p><bdi>موعد التسليم غداً</bdi></p>
</article>If a comment is known to be a complete paragraph, dir="auto" on that paragraph may be suitable. If the comment contains multiple fields inserted into a localized sentence, isolate each field instead.
Language and direction are separate. A Latin product name may need lang="en" for pronunciation while its LTR direction comes from the same narrow wrapper. R080 reports intentional foreign-language runs missing a language attribute.
Sanitize untrusted content according to the application's security rules, then apply direction in the rendering layer. Do not accept arbitrary markup solely to let authors repair bidi problems themselves.
Copying and accessibility do not prove visual order#
In the measured browser, selecting or copying the broken numeric ID returned 2026-0042, even though it appeared as 0042-2026. The accessibility tree also retained 2026-0042.
That separation is expected: logical text stays intact while visual ordering changes. It also means two common QA shortcuts miss the defect:
- copying the value into a text editor;
- checking only the accessibility-tree string.
Test the visible output separately. Use a screenshot or visual assertion with a fixture whose expected display is known. Then test logical data by copying, form submission or DOM inspection. Finally, test spoken output and language boundaries with the supported assistive technology and a fluent reviewer.
Do not “correct” logical order to satisfy a screenshot. Screen readers, copying and backend data can then receive the reversed sequence.
Debug from the smallest boundary#
When a mixed line looks wrong:
- capture the exact source string and code points if invisible controls are possible;
- confirm the paragraph's base
dir; - identify every strong Arabic and Latin run;
- mark the complete semantic values inserted into the sentence;
- remove manual string reversal and broad direction overrides;
- isolate one value at a time;
- compare visual display, copy result and submitted data;
- repeat with punctuation and a following number.
Use a table to classify the symptom:
| Symptom | First hypothesis | Test fixture |
|---|---|---|
| Hyphenated numeric groups swap | Missing isolation after Arabic | 2026-0042 |
| Latin-prefixed ID stays correct | Strong LTR prefix controls run | Compare with numeric-leading ID |
| Phone groups reverse | Unisolated mixed value | A spaced phone number with country code |
| Plus signs move before product name | Neutral punctuation joins surrounding run | C++ |
| Count moves next to Latin name | Number joins LTR run | Latin name followed by count |
| Final English full stop moves | LTR sentence lacks its own boundary | English sentence in RTL paragraph |
| Arabic-Indic phone groups reverse after isolation | Spaces resolve RTL inside digit groups | Compare <bdi> with tested <bdo> |
Do not generalize from one successful value. Include numeric-leading and Latin-leading IDs, both digit systems, values with spaces, and punctuation on both sides.
Build a bidi regression matrix#
Run the same fixtures at the beginning, middle and end of an Arabic sentence. Add a number or punctuation immediately after each value. Test the initial render and any state that replaces content dynamically.
Cover at least:
- numeric hyphenated ID;
- Latin-prefixed ID;
- numeric-leading ID with Latin suffix;
- phone with Western digits;
- phone with Arabic-Indic digits;
- email and URL;
- Latin product name with punctuation;
- Latin user name followed by a count;
- percentage, negative value and currency;
- English sentence with final punctuation;
- empty and populated auto-direction input.
Record the authored value, wrapper markup, paragraph direction, expected visual order and actual display. Browser-specific results should name the browser and version.
Ritla reports unisolated bidi hazards with R021 and punctuation at the wrong visual end with R024, on the page as rendered with its real data. Run a free scan, then reproduce each result with the product's exact value and surrounding sentence.
Base direction, the layer underneath all of this, is covered in the complete guide to dir="rtl". Phone numbers, the value most often broken by bidi, have a guide of their own.
Checks in this guide
- R021Unisolated bidi hazards: phones, emails, Latin tokens inside Arabic text
- R024Punctuation rendering at the wrong visual end of Arabic text
- R023Text inputs rendered LTR where Arabic input is expected
- R070tel/email/url inputs rendered RTL (these must stay LTR)
- R080Intentional foreign-language runs missing a lang attribute