Thai text on the web: line breaks, fonts and reading time, fixed on five sites
Browsers break Thai with a dictionary and miss loanwords in big headings; keep-all does not help. Our nowrap and spacing fixes, reading time by characters, Thai fonts.
- Browsers find Thai break points with a dictionary. Loanwords such as คลาวด์ and โปรเจกต์ are not in it, so big headings split mid-word, and word-break keep-all does not help because it is for CJK
- What works is wrapping phrases in a nowrap span, and putting spaces around loanwords in titles so lines can only break between phrases
- Thai reading time must be counted in characters (we use 900 per minute). An English-style word count (220 per minute) is off by several times
- Thai stacks vowels and tone marks above and below the line, so it needs more line-height than English, and Thai and Latin faces should share a family or a close x-height
A Thai site that looks fine on a desktop opens on a phone with the heading splitting “คลาวด์” (cloud) into “คลา” and “วด์” on two lines. The reading time says 2 minutes for an article that takes 6. And Thai and English glyphs in the same sentence sit at different heights. We hit all three building this site, then again on four more sites built on the same engine. This is what broke, why, and the code we use to fix it.
Checked in current Chrome, Safari and Firefox (October 2026) on Windows 11, macOS and iPhone, fonts from Google Fonts.
Thai has no spaces, so the browser has to guess
English breaks lines at spaces. Thai has none between words; the spaces it does have separate phrases. So the browser must work out where a word ends. The CSS Text spec says plainly that Thai, Lao and Khmer need a “lexical resource”, that is a dictionary. Chrome and Safari use ICU’s dictionary-based break iterator, which ships a Thai word list; each engine carries its own data, so results differ slightly between browsers.
These dictionaries know everyday Thai well. The trouble is loanwords: คลาวด์ (cloud), โปรเจกต์ (project), เซิร์ฟเวอร์ (server). When the segmenter does not know the whole word, it finds shorter words it does know inside it and breaks there. In 17px body text a break rarely lands inside a loanword. But an h1 at 46px on a 375px screen holds 7-8 Thai characters per line, every line has to break somewhere, and that somewhere is often the middle of a loanword.
keep-all does not help
The first thing people try is word-break: keep-all. To be plain: it does nothing for Thai. MDN defines keep-all as “word breaks should not be used for Chinese/Japanese/Korean (CJK) text; non-CJK text behavior is the same as for normal”. The CSS Text spec adds that under keep-all breaks still happen “where opportunities exist due to dictionary-based breaking”, which is exactly Thai. Every value of word-break and line-break breaks Thai at the same dictionary points, so the wrong ones stay wrong.
The browser breaks Thai with a dictionary. No CSS property teaches it new words. The fix is to tell it where it must not break.
Fix 1: wrap phrases in nowrap
Home-page headings are hand-written, so we wrap each phrase in a <span class="nw"> set to white-space: nowrap. Lines then break only between spans.
<!-- before: the browser may break anywhere the dictionary allows -->
<h1>สร้างระบบที่ทำงานได้จริง บนคลาวด์และหน้างาน</h1>
<!-- after: breaks only between phrases; คลาวด์ can never split -->
<h1><span class="nw">สร้างระบบที่ทำงานได้จริง</span> <span class="nw">บนคลาวด์และหน้างาน</span></h1>
.nw{white-space:nowrap}
A phrase must fit a 375px screen at that font size. Fourteen Thai characters at 36px is about 300px, which just fits; anything longer overflows to the right, because it is not allowed to break.
Fix 2: spaces around loanwords in article titles
Article titles come from frontmatter, so no spans there. A tiny component, ThaiTitle.astro, splits the title on spaces and wraps every piece of 14 characters or fewer in .nw; longer pieces wrap normally so nothing overflows. English passes through untouched.
const parts = text.split(' ');
{lang === 'th'
? parts.map((w, i) => <>{i > 0 && ' '}{w.length <= 14 ? <span class="nw">{w}</span> : w}</>)
: text}
So when a loanword in a Thai title keeps splitting, we put spaces around it.
# before: โปรเจกต์ may split into โปร / เจกต์
title: "ทำไมโปรเจกต์ต้องมี README ตั้งแต่วันแรก"
# after: โปรเจกต์ is its own piece, and ThaiTitle keeps it whole
title: "ทำไม โปรเจกต์ ต้องมี README ตั้งแต่วันแรก"
An extra space in a Thai title does not read oddly, because Thai already uses spaces between phrases. And the fix lives in the data, not in CSS.
The English side effect, and the 520px rule
The same .nw is used in the English hero too. On the site with a wide display face (Kanit), one English phrase came out wider than a phone and overflowed. Thai never did, since its phrases are shorter. English has spaces and the browser breaks it correctly, so on narrow screens we let English wrap normally.
/* English has spaces to break at, and a wide display face can make one phrase wider than a phone */
@media (max-width:520px){html[lang="en"] .nw{white-space:normal}}
This lives in the engine’s shared CSS, so one edit fixed all four sites.
Reading time: count characters, not words
The usual formula is word count divided by 200-250 words per minute, and in code “word count” is split(/\s+/), which counts spaces. A Thai article of 5,500 characters may contain only 400 spaces, so the formula says 2 minutes for something that takes about 6.
We use two formulas, chosen by the file’s language: Thai counts characters at 900 per minute, English counts words at 220 per minute. Code counts double in both, and diagram SVGs are stripped first.
const m = postLang(p) === 'th'
? (prose.length + codeBlocks.length * 2) / 900
: (prose.split(/\s+/).length + codeBlocks.split(/\s+/).length * 2) / 220;
return Math.max(1, Math.round(m));
The 900 comes from timing ourselves on a few of our own articles, not from research; 700 is reasonable for beginners. What matters is that the unit is characters.
Pairing Thai and English fonts
Thai typefaces split roughly into “looped” faces, with the small loops at the start of strokes seen in schoolbooks, which read comfortably at length and feel formal, and “loopless” faces that drop the loops so shapes sit closer to Latin: modern, but a few pairs (ด and ค) blur at small sizes. Our five sites choose by voice.
| Site | Headings (display) | Body | Labels / code (mono) | Why |
|---|---|---|---|---|
| 6amdev | Bai Jamjuree 600-700 | IBM Plex Sans Thai 400-600 | IBM Plex Mono | Engineering site; loopless, sharp |
| devmakerx | Chakra Petch 600-700 | IBM Plex Sans Thai | JetBrains Mono | Maker tone, squared shapes |
| mykaset | Kanit 500-600 | IBM Plex Sans Thai | IBM Plex Mono | Farming, friendly, dense bold |
| aroysure | Mitr 400-700 | IBM Plex Sans Thai Looped | Mitr | Food site; labels in Mitr read warmer than a code face |
| pickoshop | Pridi 500-600 | IBM Plex Sans Thai Looped | the body face | Shop; looped, soft, no code on the site |
- Same family first. IBM Plex Sans Thai and IBM Plex Mono share an x-height, so numbers and English words inside a Thai sentence neither float nor sink. If families differ, check the word “API” in a Thai sentence at 16px.
- Display faces that include Latin. Bai Jamjuree, Kanit, Mitr, Chakra Petch and Pridi all ship Latin glyphs, so a mixed heading is one face with no fallback mixed in.
- Weights differ per face. The engine sets headings at 700, but Mitr and Kanit at 700 are too heavy for Thai. The comment in aroysure’s tokens.css says “Mitr reads best a step lighter”, so that site overrides to 600.
- Load only the sets you use. Google Fonts serves one
@font-faceper character set; the thai set coversU+0E01-0E5Bplus a few marks, and the browser downloads it only when the page contains such characters. We request only the weights we use (Bai+Jamjuree:wght@600;700,IBM+Plex+Sans+Thai:wght@400;500;600) and adddisplay=swap.
Thai needs more line-height
Thai letters stack up to three levels: consonant, upper vowel, tone mark (as in “ที่” or “ปั้น”), plus lower vowels (อุ, อู). A line-height of 1.5, fine for English, lets the tone marks of one line touch the lower vowels of the line above. We use 1.85 for article body text at 17px, 1.7 for general text, and 1.2 for headings, where the large size already gives enough relative room; lower than that and the marks get clipped. Headings also get text-wrap: balance, which coexists with .nw because balance only chooses among break points that are already allowed.
What lang does
<html lang="th">, and lang="en" on English pages, does not make Thai breaking more accurate; browsers break Thai by script. It does three things we want: the system picks the right fallback font before the web font arrives (iOS serves Thonburi for a Thai page); hyphens: auto on English pages works, which always needs lang; and screen readers use the right voice, so the language switch link carries the lang of its target. It is also what html[lang="en"] .nw above keys on.
Common mistakes
- Adding
word-break: keep-allorline-break: strictand expecting Thai to be fixed. Both are for CJK. - Wrapping a whole heading in one nowrap; on a 375px screen it overflows at once.
- Loading every Thai weight from 100 to 900 when three are used.
- Keeping the 1.5 line-height of an English template, so tone marks collide with the line above.
- Counting reading time with
split(/\s+/)on a Thai file, which comes out about 3 times too low.
Sources
- CSS Text Module Level 3, Line Breaking Details and word-break: Thai needs a lexical resource; keep-all exempts dictionary-based breaks
- MDN: word-break: the keep-all definition for CJK
- W3C i18n: Approaches to line breaking: in Thai, Lao and Khmer spaces separate phrases, browsers use dictionaries
- Google Fonts CSS API: requesting weights, display=swap, per-subset unicode-range
FAQ
Why not insert a zero-width space (U+200B) between every Thai word?
Technically possible, but it needs a word segmenter at build time, the invisible characters leak into copy-paste and search results, and it costs time on every article. For us the problem only appears in large headings with few words, so spaces around loanwords in titles are enough.
Are Thai fonts from Google Fonts slow?
Files are split by character set and the browser downloads only the sets the page uses, via unicode-range. A Thai page loads the thai and latin sets only. We request 2-3 weights per family and add display=swap so text shows before the font arrives.
Does lang="th" make the browser break Thai better?
Not directly. Thai line breaking keys off the Thai characters in the text, not lang. But lang still drives the system's fallback font, English hyphenation, screen readers, and it is what our CSS uses to separate Thai rules from English ones.
Commands and settings were tested on sample systems. Try them on a test machine before production.