← All guides

Thai web fonts without layout shift

A Thai site that loads a webfont has a moment, somewhere between the first paint and the font's arrival, where the text is set in a fallback face. Then the webfont lands, every line re-wraps, and everything below the first paragraph moves. Google calls the result Cumulative Layout Shift and counts it against the page; readers call it "the page jumped". This guide is about why Thai pages jump more than Latin ones, how to see it happen, and what actually stops it. It is written from a measurement, not a theory: our own store had this defect and fixed it.

Why Thai jumps more

Two things happen at once when a webfont replaces a fallback.

The widths change. Every glyph in the webfont is a different width from the same glyph in the fallback, so lines break in different places. For Latin text the browser's fallback is usually a close relative of the webfont — Arial for a sans, Georgia for a serif — and the difference is a few percent. For Thai, the fallback is whatever Thai face the operating system ships: Thonburi on Apple devices, Leelawadee UI or Tahoma on Windows, Noto Sans Thai on Android. Those faces differ from a webfont like Noto Sans Thai, Prompt or Sarabun by five, ten, sometimes fifteen percent in width. A paragraph that took five lines takes six, and every element under it drops one line.

The height changes. Thai stacks vowels and tone marks above and below the consonant, and a Thai face has taller ascent and descent metrics than a Latin one to make room for them. If the fallback's metrics differ, the line box grows or shrinks when the webfont arrives — even when the widths happen to match.

And there is a third, quieter one: the spaces. Thai puts spaces between clauses rather than words, but a paragraph still has dozens of them, and a fallback face's space is often a different width from the webfont's. On our product page the space alone was 13.6 % wider in the fallback, and there were 482 of them.

Seeing it

Run Lighthouse on the page (mobile, simulated throttling) and open the Avoid large layout shifts audit. It names the element that moved and, since Lighthouse 12, the cause: web font loaded. That line is the whole diagnosis.

To see how far off the fallback is, measure the same text in both faces with a canvas in the browser console:

const text = document.body.innerText;          // the page's own words
const c = document.createElement('canvas').getContext('2d');
const width = (family) => { c.font = `400 16px ${family}`; return c.measureText(text).width; };
width('"Noto Sans Thai"') / width('Thonburi');  // 0.949 on our page: Thonburi is 5.4 % wider

The ratio is the number the fix needs.

What stops it

1. Self-host, and subset per script

Every font request to a third party adds a connection before the font can even start downloading, which lengthens the window in which the fallback is on screen. Host the files yourself, as woff2, and split them per script with unicode-range: the Thai subset is fetched only when a Thai character is laid out, the Latin one only for Latin. A Thai + Latin pair in two weights is about eight small files instead of two large ones, and a Latin page never downloads a byte of Thai.

2. Preload one face, not all of them

A preload puts the file at the front of the queue. Preload the body regular — the face most of the text is set in — and let the others follow on demand. Preloading every weight and subset just makes them compete with each other and with the page's own scripts, and the audit will say so.

3. A metric-matched fallback

This is the one that removes the shift rather than shortening it. CSS lets you declare a fallback face that is the system font, scaled so the same text takes the same width as the webfont, with the webfont's own vertical metrics:

@font-face {
	font-family: 'Noto Sans Thai Fallback';
	src: local('Thonburi');
	size-adjust: 94.92%;         /* the canvas ratio, per weight */
	ascent-override: 111.67%;    /* the webfont's ascent ÷ size-adjust */
	descent-override: 47.41%;    /* the webfont's descent ÷ size-adjust */
	line-gap-override: 0%;
	unicode-range: U+0E01-0E5B, U+200C-200D, U+25CC;
}
:lang(th) {
	font-family: 'Noto Sans Thai', 'Noto Sans Thai Fallback', system-ui, sans-serif;
}

Three details matter. The ratio is per weight: a bold fallback is not the regular one scaled. It is per script: Thai glyphs come from Thonburi, Latin letters from Arial, and the space needs a ratio of its own, so a real setup is a handful of faces, each with its unicode-range. And it is per platform: a local() font that is not installed is simply skipped, so one stack can carry a Thonburi face for Apple devices and a Tahoma face for Windows, each calibrated to its own font.

When we did this on our product page, the fallback state went from moving 177 of 190 elements to moving none in the first screen, and Lighthouse's layout-shift score went from 0.016 to 0.000. What remains is the limit of the method: size-adjust matches the aggregate width, not each glyph, so a paragraph whose last line was nearly empty can still gain or lose a line somewhere down the page.

4. Line-height that clears the marks

Thai body text wants a line-height of about 1.6 or more. Set it explicitly on the Thai subtree (:lang(th) { line-height: 1.8 } in our case) rather than leaving it to the font's metrics — then the line box is the same height whichever face is on screen, and the vertical part of the shift cannot happen. The same rule keeps stacked marks from clipping against the line above.

Doing it by hand versus letting the plugin do it

Everything above is ordinary CSS, and you can write it yourself: measure the ratios in the console, write the @font-face rules, keep them in step with the fonts you use. The catch is the upkeep — every new font, every new weight, every platform's fallback is a new measurement — and the fact that most page builders and themes will happily add a second Google Fonts request behind your back.

Design Controller does the whole list as a matter of course: a font picked from Google Fonts or Bunny Fonts is downloaded once into the WordPress Font Library as woff2, subset for Thai and Latin, printed by WordPress with at most two preloads, and paired with metric-matched fallback faces it computes from the font's own metrics. The theme's own Google Fonts request is dropped when the same family is hosted, and the Design Doctor warns about fonts loaded twice, fonts without Thai glyphs, and Thai line-height that is too tight. The rules in this guide are the ones it applies; the difference is that nobody has to remember them.

Try it on your own site

Design Controller gives WordPress one screen for its design system — Thai and Latin fonts, colours, spacing and components — without writing CSS. Free version on WordPress.org.

See what Design Controller does  Pricing