One page, three scripts: what breaks when you ship English, Persian and Sorani Kurdish
I recently rebuilt my portfolio as one page in English, Persian and Sorani Kurdish. English was easy. The other two exposed problems that no template had warned me about. This post collects the ones that actually showed up while building it, and what I did about each. Nothing here depends on a framework: the examples are plain HTML and CSS.
1. Let dir do the mirroring, and write logical CSS
Set dir="rtl" on <html> for the Persian and Kurdish pages, then stop writing left and right. Logical properties follow the writing direction on their own:
.skip { inset-inline-start: 1rem; }
.tools { margin-inline-start: auto; }
.note { border-inline-start: 4px solid; }
Flexbox and grid also follow the direction, so a header with justify-content: space-between or a two-column hero mirrors itself. The result is one stylesheet with no [dir="rtl"] overrides. The exceptions are content that is directional by nature, such as phone numbers and email addresses (see section 5).
2. Tag the language precisely
Sorani Kurdish is Central Kurdish, and its language code is ckb. The code ku is the broader Kurdish macrolanguage and doesn’t say which variety you mean. The URL can still be /ku/; only the tags need to be precise:
<html lang="ckb" dir="rtl">
<link rel="alternate" hreflang="en" href="https://example.com/">
<link rel="alternate" hreflang="fa" href="https://example.com/fa/">
<link rel="alternate" hreflang="ckb" href="https://example.com/ku/">
<link rel="alternate" hreflang="x-default" href="https://example.com/">
Every language version must list all the others and itself. If page A points to page B and B doesn’t point back, search engines may ignore the annotation. Also tag inline switches: my name appears in both scripts in the main heading, and each span carries its own lang, so screen readers change voice and the browser picks the right shaping.
3. Check glyph coverage instead of assuming it
Some fonts cover Persian but miss letters that only Sorani uses: ڕ ڵ ۆ ێ ە. A missing glyph silently falls back to another font in the middle of a word, which is easy to spot and ugly. Test a font before you choose it:
from fontTools.ttLib import TTFont # woff2 files also need the brotli package
cmap = TTFont("vazirmatn-arabic-wght-normal.woff2").getBestCmap()
needed = {"ڕ": 0x0695, "ڵ": 0x06B5, "ۆ": 0x06C6, "ێ": 0x06CE, "ە": 0x06D5}
print([ch for ch, cp in needed.items() if cp not in cmap]) # [] means all covered
Vazirmatn passed, so I used it for all three languages.
A second trap is unicode-range subsetting. Fonts are split into a Latin file and an Arabic-script file, and the browser downloads each only when it sees matching characters. My English page also contains the Arabic-script version of my name, so the browser discovered the second file late and swapped fonts after first paint. Preloading both subsets fixes it:
<link rel="preload" as="font" type="font/woff2" href="/fonts/latin.woff2" crossorigin>
<link rel="preload" as="font" type="font/woff2" href="/fonts/arabic.woff2" crossorigin>
4. Typography rules are per script
Letter-spacing. Tight tracking suits a large Latin headline. Arabic-script letters connect to each other, and spacing between them can break the joins. Limit it to the Latin spans:
.name [lang="en"] { letter-spacing: -0.035em; }
Line height. Arabic-script text needs more leading than Latin; I use about 1.95 for body text. This rule looks harmless:
:lang(fa), :lang(ckb) { line-height: 1.95; }
but it matches every element that has a lang attribute, including the <span lang="fa"> inside my hero heading. A rule that matches an element beats a value it would merely inherit, so the heading’s tight line height was overridden and the name floated far from the line above it. The fix is to give the element its own explicit value:
.name .line > span { line-height: 1.08; }
Clipping. Arabic-script glyphs have tall dots and strokes. If you hide overflow on a line for a reveal animation, add some padding and cancel it with a negative margin, or the dots get cut off.
5. Mixed-direction text: isolate what isn’t part of the sentence
Latin names inside a right-to-left sentence usually render fine. The trouble comes from neutral characters such as punctuation, the plus sign and parentheses, which can jump to the wrong side of a number or word.
- Wrap emails and phone numbers in
dir="ltr", otherwise+98 992 ...can show with the plus sign at the wrong end. - Generate list separators in CSS per language, so the Arabic comma
،is used on the right-to-left pages:
html[lang="fa"], html[lang="ckb"] { --sep: "، "; }
.inline li:not(:last-child)::after { content: var(--sep); }
- For a single term that must not interact with its surroundings, use
<bdi>orunicode-bidi: isolate.
6. Digits are a content decision
Persian uses ۰۱۲۳۴۵۶۷۸۹ (U+06F0 onward) and Arabic-Indic digits are ٠١٢٣٤٥٦٧٨٩ (U+0660 onward). The numbers 4, 5 and 6 are drawn differently in the two sets. Which one a Sorani page should use depends on the audience, so don’t type digits by hand: convert them in one function and the choice becomes a one-line change.
const sets = { en: "0123456789", fa: "۰۱۲۳۴۵۶۷۸۹", ku: "٠١٢٣٤٥٦٧٨٩" };
export const digits = (value: string | number, lang: keyof typeof sets) =>
String(value).replace(/\d/g, (d) => sets[lang][Number(d)]);
Keep Latin digits inside form fields and links, where machines read them.
7. Switching language without a framework
A language switch is an ordinary link to /fa/ or /ku/. To make it feel smooth without a JavaScript router, I opted in to cross-document view transitions in CSS:
@view-transition { navigation: auto; }
.name { view-transition-name: name; }
@media (prefers-reduced-motion: reduce) {
::view-transition-group(*), ::view-transition-old(*), ::view-transition-new(*) {
animation-duration: 0s !important;
}
}
The browser cross-fades the pages and morphs the name as the layout flips direction. At the time of writing, Chrome and Edge (126+) and Safari (18.2+) support this, and other browsers simply navigate normally, so nothing breaks. One detail: my first-visit intro animation would replay on every language switch. A one-line inline script checks that document.referrer is on the same site and adds a class that skips the intro.
Checklist
dir="rtl"plus logical CSS properties onlylang="ckb"for Sorani, reciprocalhreflanglinks andx-defaultlangon inline text in a different language- A font glyph check, and preloaded subsets
- Letter-spacing on Latin spans only, and explicit line heights on anything with a
lang dir="ltr"for emails and phone numbers, and CSS-generated separators- One digit-conversion function per site
- A native speaker reading every language: layout bugs hide in real sentences, and so do translation mistakes