Unhappy Paths

Text Is Not as Simple as It Looks

An exploration of characters, directionality and the hidden complexity of text on the web.

Issue 01Unicode & bidirectional text13 min read3 live plates14 primary sources23 test fixtures

character, to a reader
1
Unicode code points
7
for JavaScript’s .length
11
bytes in UTF-8
25

Plainاتصل على +44 20 7946 0123

Isolatedاتصل على +44 20 7946 0123

One family emoji, four different lengths. Below it, the same phone number inside an Arabic sentence, rendered by your browser with and without isolation.

A customer called سارة الخطيب places an order. Your interface greets her by name, shows her phone number, lists a keyboard with the product code 2047-KB-NL and limits her delivery note to 160 characters. Every one of those values is correct in the database. Several of them can still reach her screen in a different order from the one you stored, and the character limit depends on what you mean by a character.

None of this is exotic. It happens in ordinary interfaces, with ordinary data, the first time a product meets people who do not write the way its authors do. This issue looks at two places where text stops behaving like a simple row of letters: what counts as one character, and which direction a line of text runs.

Like every issue of the guide, it moves through four stages. You reproduce the behaviour in your own browser, understand why it happens, see how to fix it in production code, and leave with material to test your own work.

Part IStage 1 of 4: Reproduce.

What is actually one character?

Ask JavaScript how long a family emoji is and it answers 11. Ask a person and they say one. Ask a database, a network request or an HTML maxlength attribute, and you may get yet another number. They are all correct; they are counting different things.

Try the samples below, then paste something of your own: your name, a message in another script, an emoji with a skin tone.

Plate A: The character inspector

Type or paste anything, or pick a sample. The inspector counts it four ways and shows how each perceived character is built.

Runs in your browser. Nothing you type is sent or stored.

Samples

Four people joined by three ZERO WIDTH JOINER characters. Where a font supports the sequence it is drawn as one family glyph.

Grapheme clusters
1What a reader would call characters. Intl.Segmenter
Code points
7The numbers Unicode assigns. Array.from(text).length
UTF-16 code units
11What JavaScript counts. text.length
UTF-8 bytes
25What a file or network request holds. TextEncoder

  • 4 code points lie outside the Basic Multilingual Plane, so JavaScript stores each as a surrogate pair: two UTF-16 code units.
  • 3 zero width joiners glue emoji into a single sequence. Whether it is drawn as one glyph depends on the font.
  • UTF-8 uses more than one byte for every code point above U+007F, so 7 code points need 25 bytes.

Grapheme clusters

Logical (stored) order, left to right. Select one to see its code points. Arrow keys move between clusters. Arabic letters appear here in isolated forms; in a word, they join.

👨‍👩‍👧‍👦17 cp

Code points

  1. 👨U+1F4682×16 (in selected cluster)
  2. ZWJU+200D (in selected cluster)
  3. 👩U+1F4692×16 (in selected cluster)
  4. ZWJU+200D (in selected cluster)
  5. 👧U+1F4672×16 (in selected cluster)
  6. ZWJU+200D (in selected cluster)
  7. 👦U+1F4662×16 (in selected cluster)
Cluster 1 👨‍👩‍👧‍👦 is 7 code points, 11 UTF-16 code units, 25 UTF-8 bytes
CharacterCode pointNameCat.BidiUTF-16UTF-8
👨U+1F468MANSoON24
ZWJU+200DZERO WIDTH JOINERjoins emoji into one sequenceCfBN13
👩U+1F469WOMANSoON24
ZWJU+200DZERO WIDTH JOINERjoins emoji into one sequenceCfBN13
👧U+1F467GIRLSoON24
ZWJU+200DZERO WIDTH JOINERjoins emoji into one sequenceCfBN13
👦U+1F466BOYSoON24
Cut it short: what slice does to this text
MethodResultVerdict
text.slice(0, 2)👨Can split surrogate pairs and clusters
Array.from(text).slice(0, 2)👨ZWJCan split clusters
first 2 graphemes👨ZWJ👩ZWJ👧ZWJ👦Keeps clusters whole

Stage 2 of 4: Understand.

Four honest answers to “how long is this?”

Unicode gives every character a number called a code point, written U+0041 for the Latin capital A or U+0645 for the Arabic letter meem. There is room for 1,114,112 of them, from U+0000 to U+10FFFF. A code point is an abstract number; to store or send it, you need an encoding.

UTF-8 stores each code point in one to four bytes. ASCII characters take one byte, Arabic letters two, most other characters in the first 65,536 positions three, and everything above that, including most emoji, four. Files and network payloads are usually UTF-8, so this is the number that matters for size.

UTF-16 stores each code point in one or two 16-bit code units. Code points above U+FFFF need two, called a surrogate pair. JavaScript strings are sequences of UTF-16 code units, and String.prototype.length counts code units, not characters. So do indexing, slice and substring. Iterating a string with for...of or Array.from, on the other hand, walks it by code point.

None of these is what a reader means by a character. A reader sees grapheme clusters: what Unicode Standard Annex #29 calls user-perceived characters. One cluster can hold many code points:

  • a letter followed by combining marks, such as e + U+0301 COMBINING ACUTE ACCENT, or an Arabic letter carrying short-vowel marks (harakat), which the Arabic layout requirements describe as visually attached to the letter before them;
  • an emoji ZWJ sequence, where U+200D ZERO WIDTH JOINER glues several emoji together, as in the family above;
  • a flag, which is a pair of regional indicator letters;
  • an emoji with a skin tone modifier or a variation selector;
  • a carriage return followed by a line feed.
FigureOne family emoji, one grapheme cluster. Underneath: seven code points (four people, three zero width joiners), eleven UTF-16 code units (each person needs a surrogate pair, shown in blue) and 25 UTF-8 bytes. Each column is one byte.
Figureمَدْرَسَة (school), in stored order from left to right. Five letters carry four marks: nine code points, all inside the Basic Multilingual Plane, so UTF-16 uses one code unit each. Every Arabic code point takes two bytes in UTF-8. Letters are shown here in isolated forms; in the word, they join.

The modern way to find grapheme clusters in JavaScript is Intl.Segmenter with granularity: 'grapheme'. MDN lists it as Baseline, widely available across browsers since April 2024. The inspector above uses it, and if your browser does not provide it, the inspector says so instead of inventing a number.

Which count to use
When you are…CountWhy
Showing someone how many characters they have leftGrapheme clustersIt is the only count that matches what they see.
Using maxlength or minlengthUTF-16 code unitsThat is how the browser measures them, according to MDN. An emoji can use two or more of the allowance.
Indexing, slicing or comparing lengths in JavaScriptUTF-16 code unitsEvery built-in string index is a code unit index.
Estimating payload or file sizeUTF-8 bytesFor UTF-8 data, which is the common case on the web.
Respecting a storage limitWhatever your storage countsSome systems limit bytes, others code points. Check, then validate with the same unit on the server.

There is one more way two strings can disagree. The letter é can be stored as the single code point U+00E9 or as e followed by U+0301. They look identical and compare as different. String.prototype.normalize() converts both to the same form. Try the Combining accent sample above: the inspector flags text that is not in Normalization Form C.

Stage 3 of 4: Fix.

Counting and cutting text safely

The mistakes here are rarely dramatic. A character counter that disagrees with the person typing. A preview that ends in a broken emoji or the replacement character (�) because slice cut a surrogate pair in half. A unique username check that treats admin and admin followed by an invisible zero width space as different people. Open Cut it short in Plate A to watch slice split a cluster.

Count and truncate by grapheme cluster wherever the result is shown to a person. When the browser cannot segment, say so or fall back to code points, which never split a surrogate pair:

text.js
// One segmenter can be reused for every call.
const segmenter =
  'Segmenter' in Intl ? new Intl.Segmenter(undefined, { granularity: 'grapheme' }) : null;

/** User-perceived characters, or null if this browser cannot tell. */
export function countGraphemes(text) {
  if (!segmenter) return null; // Do not guess: report that you cannot count.
  let count = 0;
  for (const _ of segmenter.segment(text)) count++;
  return count;
}

/** Shortens text without cutting a perceived character in half. */
export function truncate(text, max, ellipsis = '…') {
  if (!segmenter) {
    // Fallback: code points never split surrogate pairs, but can split clusters.
    const points = Array.from(text);
    return points.length <= max ? text : points.slice(0, max).join('') + ellipsis;
  }
  let out = '';
  let count = 0;
  for (const { segment } of segmenter.segment(text)) {
    if (count++ === max) return out + ellipsis;
    out += segment;
  }
  return out;
}

A counter that people can trust uses the same function, reports through a polite live region, and leaves the hard limit to validation rather than silently cutting input:

counter.html
<label for="bio">Short bio</label>
<textarea id="bio" dir="auto" aria-describedby="bio-count"></textarea>
<p id="bio-count" aria-live="polite">0 of 160 characters</p>

<script type="module">
  import { countGraphemes } from './text.js';

  const LIMIT = 160;
  const bio = document.querySelector('#bio');
  const status = document.querySelector('#bio-count');

  bio.addEventListener('input', () => {
    const used = countGraphemes(bio.value) ?? Array.from(bio.value).length;
    status.textContent = `${used} of ${LIMIT} characters`;
    bio.setCustomValidity(used > LIMIT ? 'Please shorten your bio.' : '');
  });
</script>

Compare text after normalizing it, and remember that normalization is not sanitizing:

compare.js
// Two strings that look the same can differ in storage.
'\u00E9' === 'e\u0301';                                   // false
'\u00E9'.normalize('NFC') === 'e\u0301'.normalize('NFC'); // true

// Normalization does not remove invisible characters.
'admin\u200B'.normalize('NFC') === 'admin';               // false

Part IIStage 1 of 4: Reproduce.

Why does this sentence change direction?

Arabic is written from right to left. Numbers, and words in Latin script such as product names, still run from left to right inside it. That is normal, well-defined behaviour: browsers implement the Unicode Bidirectional Algorithm, and most mixed Arabic and English text renders exactly as a reader expects.

Trouble starts at boundaries. When the browser does not know the base direction of a paragraph, or when an inserted value sits next to text that pulls its characters in another direction, the result can be technically correct and still unreadable. The playground below builds one sentence from three parts. Every rendering you see is produced by your browser from the HTML shown under it.

Plate B: The direction playground

A sentence is built from text before, an inserted value and text after. Edit any part, change the directions, and compare four ways of marking it up, rendered by your browser.

Runs in your browser. Nothing you type is sent or stored.

Page direction inherited by the paragraph
Declared direction for explicit dir
Direction of the bdi for the isolated value

Without isolation, the digit groups are separate numbers separated by spaces, so in a right-to-left paragraph they are laid out right to left: 0123 7946 20 44+. Phone numbers are conventionally read left to right as one unit. <bdi> keeps them together; because a phone number has no strongly directional characters, an explicit dir="ltr" on the <bdi> states the intent instead of relying on the fallback.

What the browser receives

Characters in stored (logical) order, always shown left to right, each with its Unicode bidirectional class. Hover a character for the full class name.

  1. اALfirst strong
  2. تAL
  3. صAL
  4. لAL
  5. ␣WS
  6. بAL
  7. نAL
  8. اAL
  9. ␣WS
  10. عAL
  11. لAL
  12. ىAL
  13. ␣WS
  14. +ES
  15. 4EN
  16. 4EN
  17. ␣WS
  18. 2EN
  19. 0EN
  20. ␣WS
  21. 7EN
  22. 9EN
  23. 4EN
  24. 6EN
  25. ␣WS
  26. 0EN
  27. 1EN
  28. 2EN
  29. 3EN
  30. ␣WS
  31. مAL
  32. نAL
  33. ␣WS
  34. اAL
  35. لAL
  36. أAL
  37. حAL
  38. دAL
  39. ␣WS
  40. إAL
  41. لAL
  42. ىAL
  43. ␣WS
  44. اAL
  45. لAL
  46. خAL
  47. مAL
  48. يAL
  49. سAL
  50. .CS

First strong character: ا (right-to-left), so dir="auto" should resolve this paragraph as RTL.

Inherited direction

Use when the page or container already has the right direction.

اتصل بنا على +44 20 7946 0123 من الأحد إلى الخميس.

HTML
<!-- inside an element (or page) with dir="rtl" -->
<p>اتصل بنا على +44 20 7946 0123 من الأحد إلى الخميس.</p>

No dir attribute: the paragraph takes its base direction from its parent. This is how most text on a correctly set up page should work.

Explicit dir

Use when you know the language of the block.

اتصل بنا على +44 20 7946 0123 من الأحد إلى الخميس.

HTML
<!-- inside an element (or page) with dir="rtl" -->
<p dir="rtl">اتصل بنا على +44 20 7946 0123 من الأحد إلى الخميس.</p>

dir sets the base direction of the whole block. Use it for content whose language you know, such as a translated string or an article body.

dir="auto"

Use for whole blocks of unknown direction, such as user comments.

اتصل بنا على +44 20 7946 0123 من الأحد إلى الخميس.

HTML
<!-- inside an element (or page) with dir="rtl" -->
<p dir="auto">اتصل بنا على +44 20 7946 0123 من الأحد إلى الخميس.</p>

The browser picks the direction from the first strongly directional character it finds. It is a heuristic, not language detection.

Isolated with <bdi>

Use for a value of unknown direction inserted into known text.

اتصل بنا على +44 20 7946 0123 من الأحد إلى الخميس.

HTML
<!-- inside an element (or page) with dir="rtl" -->
<p>اتصل بنا على <bdi>+44 20 7946 0123</bdi> من الأحد إلى الخميس.</p>

The block keeps the inherited direction; <bdi> isolates the inserted value so it neither affects nor is affected by the text around it.

Stage 2 of 4: Understand.

Base direction, strong characters and neutrals

Text is stored in logical order: the order in which it is read and typed. The bidirectional algorithm works out the visual order for each line. To do that it needs two things: the base direction of the paragraph, and the bidirectional class of every character. The strip in Plate B shows both for whatever you type.

The base direction comes from HTML. An element with dir="rtl" or dir="ltr" sets it explicitly. An element without dir inherits it from its parent, and if no ancestor sets it, it is left to right. With dir="auto", the browser looks for the first character with a strong direction and uses that, skipping things such as nested <bdi> elements and elements with their own dir. This mirrors rules P2 and P3 of the algorithm.

Each character’s class is fixed by Unicode. The ones that matter most in practice:

Bidirectional classes you will meet
KindClassExamples
StrongLLatin letters; also the invisible LEFT-TO-RIGHT MARK
StrongAL, RArabic letters (AL); Hebrew letters (R)
WeakENDigits 0–9, and the Extended Arabic-Indic digits ۰–۹ used for Persian
WeakANArabic-Indic digits ٠–٩
WeakES, ET, CS+ - (separators), # $ % € (terminators), . , : / and the Arabic comma ، (common separators)
WeakNSMCombining marks, including Arabic harakat
NeutralWS, ONSpaces; most other punctuation and symbols: ( ) ! ? @ < > and emoji
ExplicitLRI, RLI, FSI, PDIInvisible isolate controls, U+2066 to U+2069

Three behaviours of the algorithm explain every scenario in the playground.

Neutral characters take sides

A neutral character between two strong characters of the same direction takes that direction. Anywhere else, at the edge of a run, between opposite directions, or at the end of a paragraph, it takes the direction of the paragraph (rules N1 and N2). That is why the slash at the end of https://example.com/ar/ and the exclamation mark at the end of Hello, world! move to the far side in a right-to-left paragraph: as far as Unicode can tell, they belong to the Arabic sentence.

Numbers borrow their context

Digits are weak. After Arabic letters, European digits are treated as Arabic numbers (rule W2); after Latin letters, they are treated as part of the Latin text (rule W7). So KB-2047-NL stays in one piece inside Arabic, while 2047-KB-NL does not: its leading digits attach to the Arabic words before them. In an English sentence, the same rule pulls the : 3 after an Arabic name into the name’s right-to-left run. Groups of digits separated by spaces are separate numbers, so a phone number in a right-to-left paragraph is laid out group by group from right to left. The Arabic layout requirements describe this weak behaviour of numbers and give phone numbers as an example of numerals mixed with spaces and signs.

Brackets are mirrored

In right-to-left text, characters such as (, ), [ and < are drawn with mirrored glyphs (rule L4), and matching pairs of brackets are resolved together (rule N0). In the Parentheses and numbers scenario the parentheses still look right, but the parenthesised offset ends up on the other side of the time.

Block direction and inline direction are different jobs

dir on a block element such as <p> sets the base direction of a paragraph: where lines start, which side neutral characters at the ends fall to, which way the text aligns. dir on an inline element, and the <bdi> element, creates an isolate: a piece of text whose direction is worked out on its own and which, seen from outside, behaves like a single neutral character. According to MDN, isolation works both ways: the text inside does not influence its surroundings, and is not influenced by them.

You can see the difference in the Arabic name followed by an email address scenario. Switch the page direction to RTL: <bdi> still isolates the name, but the English sentence around it is now a right-to-left paragraph, and isolation cannot fix that. The block needs the right direction and the value needs isolation.

Choosing an approach

  • Inherit whenever possible. Put lang and dir on the <html> element once and let content inherit it. The W3C recommends doing this in markup rather than with the CSS direction property, and MDN explains why: direction belongs to the content, not its presentation.
  • Explicit dir on a block when you know its language, for example an English quotation inside an Arabic page.
  • dir="auto" on a block whose direction you cannot know, such as a user comment or a chat message. It is a heuristic: MDN describes it as determining direction from the first strong character, “not language detection”. An English sentence that begins with an Arabic name becomes right to left; an Arabic sentence that begins with an email address becomes left to right; text with no strong characters at all falls back to left to right. If you know the direction, for example from the language a person chose, set it explicitly.
  • <bdi> around a value of unknown direction inserted into text you control: names, titles, anything interpolated into a template. It behaves as if dir="auto" were set. MDN notes that <bdi dir="ltr"> and <span dir="ltr"> are exactly equivalent, and that a semantic element with dir="auto", such as <cite dir="auto">, is preferable to <bdi> where one fits.
  • dir="ltr" on an inline wrapper for values that are always left to right: phone numbers, product codes, email addresses, URLs, code.
direction.html
<!-- Set the document direction once, in markup, with the language. -->
<html lang="ar" dir="rtl">

<!-- A block whose direction you know (a quotation in English). -->
<blockquote lang="en" dir="ltr">…</blockquote>

<!-- A block whose direction you do not know (a user comment). -->
<p class="comment" dir="auto">…</p>

<!-- A value of unknown direction inside text you control. -->
<p>تعليق جديد من <bdi>{{ user.name }}</bdi></p>

<!-- A value that is always left to right. -->
<p>للتواصل: <span dir="ltr">+44 20 7946 0123</span></p>

Form fields follow the same logic. The W3C recommends dir="auto" on inputs and textareas so text aligns with what is typed, and the dirname attribute sends the field’s direction to the server along with its value, so you can display it correctly later instead of guessing again. The Arabic Script Gap Analysis records dirname support as fixed in all three major browser engines.

form.html
<!-- Let the field follow what the person types... -->
<input name="comment" dir="auto" dirname="comment.dir">

<!-- ...but phone numbers, emails and URLs are left to right. -->
<input type="tel"   name="phone" dir="ltr" autocomplete="tel">
<input type="email" name="email" dir="ltr" autocomplete="email">

Some places cannot contain markup: document.title, title attributes, <option> text, notifications. There, Unicode’s isolate characters do the same job as <bdi> and dir. The W3C’s article on inline markup and bidirectional text recommends markup wherever it is available and isolates (rather than the older embedding controls) where it is not.

no-markup.js
// Where markup is impossible (document.title, a title attribute,
// a notification), use Unicode isolates around the inserted value.
const FSI = '\u2068'; // FIRST STRONG ISOLATE: like <bdi> / dir="auto"
const LRI = '\u2066'; // LEFT-TO-RIGHT ISOLATE: like dir="ltr"
const PDI = '\u2069'; // POP DIRECTIONAL ISOLATE: closes either

document.title = `${FSI}${conversation.name}${PDI} · Messages`;
input.title = `رقم الهاتف: ${LRI}${phone}${PDI}`;

Stage 3 of 4: Fix.

A production pattern

Isolation is easiest to get right when it is part of how an interface is built rather than a fix applied to individual strings. Here is an order summary from a shop that sells in Arabic and English. Switch to Plain markup to see what happens without isolation, and switch the interface language to see that the same component works in both directions.

Plate C: An order summary that survives real data

Arabic customer name, English product titles, identifiers, a phone number and prices in one interface. Switch the interface language and compare plain markup with isolated markup.

Runs in your browser. Nothing you type is sent or stored.

Interface
Markup

ملخص الطلب

طلب رقم #1042 ·

العميل
سارة الخطيب
الهاتف
+44 20 7946 0123
البريد الإلكتروني
sara@example.com

المنتجات

  • Aurora Mechanical Keyboard, 75% layout

    رمز المنتج 2047-KB-NL · الكمية 1

    149.00 USD

  • USB-C cable (2 m)

    رمز المنتج 88-USBC-2M · الكمية 2

    12.50 USD

الشحن
مجاني
الإجمالي
174.00 USD

سنتواصل مع سارة الخطيب على الرقم +44 20 7946 0123 قبل التوصيل.

In plain mode, look at the phone number, the order number, the first SKU and, in English, the sentence that starts with the customer’s name. In isolated mode, each value is kept together; the interface around it still follows the page direction.

HTML · isolated (recommended)
<section class="order" dir="rtl" lang="ar" aria-label="ملخص الطلب">
  <header class="order__head">
    <h3>ملخص الطلب</h3>
    <p>طلب رقم <span dir="ltr">#1042</span> · <time datetime="2026-11-02">2 نوفمبر 2026</time></p>
  </header>
  <dl class="order__details">
    <div><dt>العميل</dt><dd><bdi>سارة الخطيب</bdi></dd></div>
    <div><dt>الهاتف</dt><dd><a href="tel:+442079460123" dir="ltr">+44 20 7946 0123</a></dd></div>
    <div><dt>البريد الإلكتروني</dt><dd><a href="mailto:sara@example.com" dir="ltr">sara@example.com</a></dd></div>
  </dl>
  <h4 class="order__subhead">المنتجات</h4>
  <ul class="order__items">
    <li class="order__item">
      <p class="order__title"><bdi>Aurora Mechanical Keyboard, 75% layout</bdi></p>
      <p class="order__meta">رمز المنتج <span translate="no" dir="ltr">2047-KB-NL</span> · الكمية 1</p>
      <p class="order__price"><data value="149.00" dir="ltr">149.00 USD</data></p>
    </li>
    <li class="order__item">
      <p class="order__title"><bdi>USB-C cable (2 m)</bdi></p>
      <p class="order__meta">رمز المنتج <span translate="no" dir="ltr">88-USBC-2M</span> · الكمية 2</p>
      <p class="order__price"><data value="12.50" dir="ltr">12.50 USD</data></p>
    </li>
  </ul>
  <dl class="order__totals">
    <div><dt>الشحن</dt><dd>مجاني</dd></div>
    <div class="order__grand"><dt>الإجمالي</dt><dd><data value="174.00" dir="ltr">174.00 USD</data></dd></div>
  </dl>
  <p class="order__note">سنتواصل مع <bdi>سارة الخطيب</bdi> على الرقم <span dir="ltr">+44 20 7946 0123</span> قبل التوصيل.</p>
</section>

The implementation follows a few rules that hold for most interfaces:

  1. The component declares its own language and direction (lang and dir on the root). Everything inside inherits them.
  2. Values of unknown direction are wrapped in <bdi>. The customer’s name and product titles come from people and catalogues, so the interface cannot know their direction in advance.
  3. Values that are always left to right get dir="ltr". The phone number, email address, order number, product codes and amounts. Any element with a dir attribute is isolated from its surroundings, just like <bdi>.
  4. Semantics stay intact. Links remain links (tel: and mailto:), the date is a <time> element with a machine-readable value, prices use <data>, and product codes carry translate="no" so translation tools leave them alone.
  5. Layout uses logical CSS properties, so nothing in the stylesheet needs to know which way the interface runs.
order.css
.order {
  text-align: start;                 /* not left */
}
.order__note {
  border-inline-start: 3px solid;    /* not border-left */
  padding-inline: 12px;              /* not padding-left/right */
}
.order dd {
  text-align: end;                   /* not right */
}
/* Phone numbers, codes and amounts should not break across lines. */
.order [dir="ltr"] {
  white-space: nowrap;
}
/* When an icon must point "forward", flip it for right-to-left text. */
.icon-forward:dir(rtl) {
  transform: scaleX(-1);
}

Stage 4 of 4: Test.

Test it in your own interface

Bidirectional bugs survive code review because the source looks fine. They show up when real data meets real layouts, so the most effective test is to put realistic strings into your interface and look.

Every row can be copied as the literal text or as a JavaScript string with escapes, which survives editors and code review. The same data is available as JSON; the expected counts are checked by this site’s unit tests.

Counting fixtures
CaseTextGraph.CPUTF-16UTF-8Copy
Plain ASCIIhello5555
é, precomposed (NFC)é1112
é, decomposed (NFD)Looks identical to the previous row; compares unequal unless both are normalized.é1223
Arabic word with diacriticsمَدْرَسَة59918
Arabic, English and emojiمرحبًا Sara 👋🏽12141626
Single emoji🌍1124
Emoji with skin tone modifier👋🏽1248
Family, ZWJ sequenceDrawn as one glyph only where the font supports the sequence; otherwise as several people side by side.👨‍👩‍👧‍👦171125
Flag (regional indicators J + P)🇯🇵1248
Keycap digit one1️⃣1337
Text ending in a zero width spaceRenders exactly like "admin".admin​6668
Windows line ending between lettersCR LF is a single grapheme cluster.a b3444
Empty string(empty)0000
Direction fixtures
CaseTextWhat to checkCopy
Phone number inside Arabic\u0627\u062A\u0635\u0644 \u0628\u0646\u0627 \u0639\u0644\u0649 +44 20 7946 0123 \u0645\u0646 \u0627\u0644\u0623\u062D\u062F \u0625\u0644\u0649 \u0627\u0644\u062E\u0645\u064A\u0633.The phone number reads +44 20 7946 0123, left to right, as one unit.
Identifier starting with digits\u0631\u0645\u0632 \u0627\u0644\u0645\u0646\u062A\u062C 2047-KB-NL \u0645\u062A\u0648\u0641\u0631 \u062D\u0627\u0644\u064A\u064B\u0627.The identifier is displayed as 2047-KB-NL, not split around the Arabic text.
URL ending in a slash\u0632\u0648\u0631\u0648\u0627 https://example.com/ar/ \u0644\u0644\u0645\u0632\u064A\u062F \u0645\u0646 \u0627\u0644\u0645\u0639\u0644\u0648\u0645\u0627\u062A.The trailing slash stays at the end of the URL.
Arabic name, colon, number in English text\u0633\u0627\u0631\u0629 \u0627\u0644\u062E\u0637\u064A\u0628: 3 new messagesIn a left-to-right interface, “: 3” stays after the name, on the right.
English sentence that starts with an Arabic name\u0633\u0627\u0631\u0629 \u0627\u0644\u062E\u0637\u064A\u0628 replied to your commentIn an English interface the sentence is laid out left to right, even though its first strong character is Arabic.
Arabic sentence that starts with an email addresssara@example.com \u0647\u0648 \u0628\u0631\u064A\u062F\u064A \u0627\u0644\u062C\u062F\u064A\u062FIn an Arabic interface (or with an explicit dir="rtl") the sentence is right to left, even though it starts with Latin letters.
English quotation ending in punctuation inside Arabic\u0643\u062A\u0628 \u0627\u0644\u0645\u0633\u062A\u062E\u062F\u0645: Hello, world!The exclamation mark stays at the end of “Hello, world!”.
Text without strong characters+44 (0) 20 7946 0123With dir="auto" this resolves to left to right because there is no strong character; check your UI does not depend on that by accident.
Right-to-left override in a file nameinvoice\u202Etxt.exeRenders as “invoiceexe.txt” in most contexts. Interfaces that show file names or other untrusted text should make bidi controls visible or remove them.
Text with an explicit isolate (FSI…PDI)\u0627\u0644\u0645\u0633\u062A\u062E\u062F\u0645 \u2068Sara Khatib\u2069 \u0645\u062A\u0635\u0644Isolate controls work where markup is impossible, e.g. in title attributes or notifications. The name stays together.

A checklist for text and direction

  • The <html> element has the right lang and dir, set in markup.
  • Every value inserted into a sentence or label is isolated: <bdi> when its direction is unknown, dir="ltr" when it is always left to right.
  • User-generated blocks and text fields use dir="auto", and you know which content would fool its first-strong-character heuristic.
  • Phone numbers, email addresses, URLs, codes and identifiers render as one left-to-right unit inside right-to-left text.
  • Character counters count grapheme clusters, and the client and the server agree on what a limit means.
  • Nothing visible to a person is shortened with slice, substring or substr.
  • Text is normalized before it is compared, deduplicated or used as a key.
  • Where spoofing matters (usernames, file names), bidirectional control characters such as U+202E RIGHT-TO-LEFT OVERRIDE are removed or made visible.
  • CSS uses logical properties, and icons that point “forward” are mirrored deliberately.
  • The interface has been looked at, not just tested, in both directions, with the fixtures above.

Automating what can be automated

Counting is easy to unit test, because it has exact answers:

text.test.js
import { expect, test } from 'vitest';

const seg = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
const graphemes = (s) => [...seg.segment(s)].length;

test('a family emoji is one character to a reader', () => {
  const family = '\u{1F468}\u200D\u{1F469}\u200D\u{1F467}\u200D\u{1F466}';
  expect(graphemes(family)).toBe(1);
  expect(family.length).toBe(11);
});

Direction is harder. A browser test can check that markup is in place and that an element resolved to the direction you expect, using the :dir() pseudo-class, which the Arabic Script Gap Analysis reports as supported in Gecko, Blink and WebKit:

order.spec.js
import { expect, test } from '@playwright/test';

// The inserted name must not change the direction of the sentence around it.
test('customer name is isolated', async ({ page }) => {
  await page.goto('/orders/1042');
  const note = page.locator('.order__note');
  expect(await note.evaluate((el) => el.matches(':dir(rtl)'))).toBe(true);
  await expect(note.locator('bdi')).toHaveText('سارة الخطيب');
});

What no DOM assertion can tell you is the visual order of the characters on screen. For that, use screenshot comparisons of the fixtures in both directions, and a person who reads the language.

Text looks simple because so much work goes into making it look that way. Most of that work is done by Unicode and the browser. The part left to us is small, but it is ours: say what direction things are, keep inserted values apart from the sentences around them, and count characters the way people do.

Sources

Primary sources used for this issue. Behaviour described as “your browser” on this page was rendered live; claims about the algorithm are taken from these documents.

  1. Unicode Standard Annex #9: Unicode Bidirectional AlgorithmUnicode ConsortiumThe algorithm browsers use to order mixed-direction text. Rules P2–P3, W2, W7, N0–N2 and L4 are cited below.
  2. Unicode Standard Annex #29: Unicode Text SegmentationUnicode ConsortiumDefines grapheme clusters, the basis of Intl.Segmenter’s grapheme granularity.
  3. Structural markup and right-to-left text in HTMLW3C InternationalizationMarked as outdated by the W3C; its current guidance lives in the techniques index below. Still a useful overview.
  4. Authoring HTML: Handling right-to-left scriptsW3C Internationalization
  5. Inline markup and bidirectional text in HTMLW3C Internationalization
  6. Arabic & Persian Layout RequirementsW3CIncludes the treatment of numbers, numerals and bidirectional text in Arabic script.
  7. Arabic Script Gap AnalysisW3CTracks where browsers and specifications still fall short for Arabic script, including the :dir() selector and dirname support.
  8. dir (global attribute)MDN Web Docs
  9. <bdi>: The Bidirectional Isolate elementMDN Web Docs
  10. Intl.SegmenterMDN Web Docs
  11. HTML attribute: maxlengthMDN Web Docs
  12. String.prototype.lengthMDN Web Docs
  13. String.prototype.normalize()MDN Web Docs
  14. HTML attribute: dirnameMDN Web Docs