CSS Module 13 – International layout: logical properties, writing modes and Indian scripts
Typography for Indian scripts
Tag languages, build font stacks, and set line height, underlines, drop caps, spacing and list numbers that suit Devanagari, Tamil and Urdu.
What you will learn
- Set lang correctly and build font stacks for Devanagari, Bengali, Tamil and other Indian scripts
- Calculate a line height that keeps vowel signs and stacked conjuncts of two lines apart
- Avoid underlines that cut through vowel signs, with text-underline-offset and text-underline-position
- Apply drop caps, letter-spacing and list numbering the way Indic typography expects
Before you start
What you’ll learn · Before you start
What you will learn
- Set lang correctly and build font stacks for Devanagari, Bengali, Tamil and other Indian scripts
- Calculate a line height that keeps vowel signs and stacked conjuncts of two lines apart
- Avoid underlines that cut through vowel signs, with text-underline-offset and text-underline-position
- Apply drop caps, letter-spacing and list numbering the way Indic typography expects
Before you start
On this page
Text in Devanagari, Bengali, Tamil, Urdu and the other scripts of India needs three things from CSS that English rarely
asks for. It needs the right language tag, because lang decides which font a browser picks and even which shape a
letter takes. It needs more line height, because vowel signs and stacked consonants reach far above and below the
letters: in Noto Sans Devanagari the font’s own line height leaves 0.02 em between the marks of two lines. And it needs
care with every effect that works on “letters”, such as drop caps and letter-spacing, because the unit a reader sees in
these scripts is a whole syllable, often several Unicode characters long.
This lesson sets up a bilingual notice board and measures each of those in Chromium 153, Firefox 155 and WebKit 26.6, with the same self-hosted Noto fonts in all three, so the differences come from the engines, not the fonts.
Tag every passage with lang
lang names the language of an element and everything inside it: <html lang="en"> for an English page,
<p lang="hi"> for a Hindi paragraph in it. The value is a BCP 47 tag: hi Hindi, mr Marathi, ne Nepali, bn
Bengali, as Assamese, pa Punjabi, gu Gujarati, or Odia, ta Tamil, te Telugu, kn Kannada, ml Malayalam,
ur Urdu, sa Sanskrit. Add a script subtag only when the language is written in more than one script and you need to
tell them apart, the W3C advises: pa-Arab for Punjabi in Shahmukhi, or hi-Latn for Hindi typed in Latin letters.
The tag changes what the reader sees. The notice board sets one font stack and line height per language with :lang():
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>A bilingual notice board</title>
<style>
/* Self-hosted Noto fonts (SIL Open Font License 1.1) from notofonts.github.io, served from /fonts/. */
@font-face {
font-family: "Noto Sans Devanagari";
src: url("/fonts/NotoSansDevanagari-Regular.ttf");
font-display: swap;
}
body { margin: 16px; font: 18px/1.5 system-ui, sans-serif; }
/* One rule per language: the font stack and the line height that script needs. */
:lang(hi), :lang(mr) {
font-family: "Noto Sans Devanagari", "Nirmala UI", "Kohinoor Devanagari", sans-serif;
line-height: 1.75;
}
:lang(hi) a, :lang(mr) a { text-underline-offset: 0.35em; }
.system { font-family: system-ui, sans-serif; } /* no Devanagari font named: the system picks one */
.forms { font: 48px/1.4 "Noto Sans Devanagari", sans-serif; } /* one font: only the language tag differs */
.forms span { display: inline-block; }
</style>
</head>
<body>
<p data-fonts="English paragraph">The library opens at 9 a.m. on weekdays.</p>
<p lang="hi" data-fonts="Hindi, own font stack">पुस्तकालय सोमवार से शुक्रवार सुबह 9 बजे खुलता है।</p>
<p lang="hi" class="system" data-fonts="Hindi, system fonts only">पुस्तकालय सोमवार से शुक्रवार सुबह 9 बजे खुलता है।</p>
<p lang="mr" data-fonts="Marathi, own font stack">ग्रंथालय सोमवार ते शुक्रवार सकाळी 9 वाजता उघडते.</p>
<!-- The same words in the same font, tagged as Hindi, Marathi and Nepali. -->
<p class="forms"><span lang="hi">शाला झरना</span> <span lang="mr">शाला झरना</span> <span lang="ne">शाला झरना</span></p>
<script>
document.fonts.ready.then(() => {
for (const p of document.querySelectorAll('p[data-fonts]')) {
const cs = getComputedStyle(p);
console.log(`${p.dataset.fonts}: lang ${p.lang || 'en'}, line-height ${cs.lineHeight}, first family ${cs.fontFamily.split(',')[0]}`);
}
// Different glyphs have different widths: the same two words under three language tags.
for (const span of document.querySelectorAll('.forms span')) {
console.log(`lang ${span.lang}: "शाला झरना" is ${span.getBoundingClientRect().width.toFixed(1)} px wide`);
}
});
</script>
</body>
</html> Its last line repeats the words शाला झरना in one font under three tags, and the shapes differ.
With lang="mr" the letter श took its Marathi form, and with lang="ne" the letter
झ its Nepali form; the font carries all three designs and picks one by language. The widths
were 197.5 px for Hindi, 198.1 to 198.2 px for Marathi and 205.6 px for Nepali in all three engines. The tag does more:
screen readers choose a voice by it, hyphens: auto needs it, and with no font named, WebKit drew lang="ur" text in a
Nastaliq font while drawing the same letters tagged ar in a Naskh one; Chromium and Firefox used the Naskh face for
both.
Two details of :lang() matter here. It matches by prefix and through inheritance, so :lang(hi) also matches
lang="hi-IN" and every element inside a Hindi section. And the newer forms, a list such as :lang(hi, mr) or a
wildcard such as :lang("*-IN"), worked in Firefox and WebKit but not in Chromium 153, which drops the whole rule;
Baseline lists them as limited. Write :lang(hi), :lang(mr) instead.
Government websites and apps
India’s Guidelines for Indian Government Websites and Apps (GIGW 3.0) require Unicode for Hindi and regional-language
content and testing in several browsers for loss of layout (checkpoints 5.1.13 and 5.4.10). Text typed with a legacy
font such as Kruti Dev is not Unicode Hindi: it is stored as the Latin characters the font happens to draw as Devanagari
shapes, so नमस्ते is saved as the letters ueLrs, and lang, font stacks, search and screen
readers all see those Latin letters. Convert such text to Unicode before you style it.
Font stacks per script
A font stack for an Indian script names fonts in this order: the one you serve, then the ones each operating system ships, then a generic family.
- Windows ships Nirmala UI, which covers Bengali, Devanagari, Gujarati, Gurmukhi, Kannada, Malayalam, Odia, Tamil and Telugu, and also Sinhala, Ol Chiki, Meetei Mayek, Chakma and Sora Sompeng, according to Microsoft’s font list. Serving it from your own site needs a separate licence, as that page explains, so name it as a local font only.
- macOS and iOS ship separate families: Apple’s font list includes Kohinoor Devanagari, Kohinoor Bangla, Kohinoor
Gujarati and Kohinoor Telugu, ITF Devanagari with a Marathi variant, the Sangam MN families, InaiMathi for Tamil and
Noto Nastaliq Urdu. With
font-family: system-ui, Chromium on the measuring Mac drew the Hindi letters with.SF Devanagariand the digit and spaces with.SF NS, its Latin system font. - A self-hosted font gives every reader the same shapes and metrics. Noto Sans Devanagari is released under the SIL Open Font License 1.1, which allows serving it from your own site.
Chromium’s DevTools protocol reports which font drew each glyph of the notice board. For the Hindi paragraph with the
stack above, all 46 glyphs came from the web font, the digit 9 included; for the same paragraph with system-ui only,
36 glyphs came from .SF Devanagari and 9 from .SF NS. A page that mixes system fonts in one line also mixes their
metrics, which is the next section’s problem.
Serving one file per script keeps pages light. The TrueType files of the Noto fonts used here weigh 243,520 bytes for
Devanagari, 143,072 for Bengali and 73,992 for Tamil, and WOFF2 compression and subsetting make them much smaller. Give
each script its own @font-face rule with a unicode-range covering its block (U+0900–097F for Devanagari, for
example), under one family name if you like. A browser then downloads a file only when the page uses a character in
its range.
Give the marks room: line height
Measured with canvas measureText() on each script’s tallest and deepest syllables, the ink of these fonts reaches this
far from the baseline, in em (Chromium and Firefox agreed within 0.01 em; the list keeps the largest value of the
three engines):
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>How far the ink reaches: Indian scripts</title>
<style>
/* Self-hosted Noto fonts (SIL Open Font License 1.1) from notofonts.github.io, served from /fonts/. */
@font-face { font-family: "Noto Sans"; src: url("/fonts/NotoSans-Regular.ttf"); }
@font-face { font-family: "Noto Sans Devanagari"; src: url("/fonts/NotoSansDevanagari-Regular.ttf"); }
@font-face { font-family: "Noto Sans Bengali"; src: url("/fonts/NotoSansBengali-Regular.ttf"); }
@font-face { font-family: "Noto Sans Gujarati"; src: url("/fonts/NotoSansGujarati-Regular.ttf"); }
@font-face { font-family: "Noto Sans Gurmukhi"; src: url("/fonts/NotoSansGurmukhi-Regular.ttf"); }
@font-face { font-family: "Noto Sans Oriya"; src: url("/fonts/NotoSansOriya-Regular.ttf"); }
@font-face { font-family: "Noto Sans Tamil"; src: url("/fonts/NotoSansTamil-Regular.ttf"); }
@font-face { font-family: "Noto Sans Telugu"; src: url("/fonts/NotoSansTelugu-Regular.ttf"); }
@font-face { font-family: "Noto Sans Kannada"; src: url("/fonts/NotoSansKannada-Regular.ttf"); }
@font-face { font-family: "Noto Sans Malayalam"; src: url("/fonts/NotoSansMalayalam-Regular.ttf"); }
@font-face { font-family: "Noto Nastaliq Urdu"; src: url("/fonts/NotoNastaliqUrdu-Regular.ttf"); }
</style>
</head>
<body>
<script>
// For each script: its font and syllables with marks above and below the letters.
const SAMPLES = [
['Latin', 'Noto Sans', 'Éèg Åjy Çpq'],
['Devanagari', 'Noto Sans Devanagari', 'कि की कीं कँ र्कि कु कू कृ द्ध द्धृ ष्ट्र'],
['Bengali', 'Noto Sans Bengali', 'কি কী কঁ র্কি কু কূ কৃ ক্র স্ত্র'],
['Gujarati', 'Noto Sans Gujarati', 'કિ કી કં ર્કિ કુ કૂ કૃ દ્ધ'],
['Gurmukhi', 'Noto Sans Gurmukhi', 'ਕਿ ਕੀ ਕੈਂ ਕੁ ਕੂ ਕ੍ਰ'],
['Odia', 'Noto Sans Oriya', 'କି କୀ କୁ କୂ କୃ ସ୍ତ୍ର'],
['Tamil', 'Noto Sans Tamil', 'கி கீ கு கூ ஞு ஸ்ரீ'],
['Telugu', 'Noto Sans Telugu', 'కి కీ కు కూ కృ స్త్ర'],
['Kannada', 'Noto Sans Kannada', 'ಕಿ ಕೀ ಕು ಕೂ ಕೃ ಸ್ತ್ರ'],
['Malayalam', 'Noto Sans Malayalam', 'കി കീ കു കൂ കൃ സ്ത്ര'],
['Urdu (Nastaliq)', 'Noto Nastaliq Urdu', 'یہ کتاب بہت مشہور ہے نستعلیق خط'],
];
const SIZE = 100; // px, so the numbers below read as hundredths of an em
window.measure = async () => {
const ctx = document.createElement('canvas').getContext('2d');
const out = [];
for (const [script, font, text] of SAMPLES) {
await document.fonts.load(`${SIZE}px "${font}"`, text);
ctx.font = `${SIZE}px "${font}"`;
let top = 0, bottom = 0;
for (const syllable of text.split(' ')) {
const m = ctx.measureText(syllable);
top = Math.max(top, m.actualBoundingBoxAscent);
bottom = Math.max(bottom, m.actualBoundingBoxDescent);
}
const f = ctx.measureText(text);
const em = (n) => (n / SIZE).toFixed(2);
out.push(`${script}: ink ${em(top)} em above the baseline, ${em(bottom)} em below, ${em(top + bottom)} em in all; ` +
`the font's line box ${em(f.fontBoundingBoxAscent + f.fontBoundingBoxDescent)} em`);
}
return out;
};
</script>
</body>
</html> - Latin: 0.94 above and 0.24 below the baseline; the font’s own line box is 1.36.
- Devanagari: 0.90 above and 0.39 below; line box 1.31.
- Bengali: 0.92 above and 0.27 below; line box 1.33.
- Tamil: 0.84 above and 0.32 below; line box 1.24.
- Telugu: 0.87 above and 0.47 below; line box 1.35.
- Kannada: 0.79 above and 0.46 below; line box 1.35.
- Urdu in Noto Nastaliq Urdu: 1.62 above and 0.46 below; line box 2.50.
The line box is the height the font itself asks for, which line-height: normal uses. Above plus below gives the ink
in all: 1.29 em for Devanagari and 2.08 em for Nastaliq, against 1.18 em for Latin.
The deepest Devanagari ink belonged to द्धृ, a stacked conjunct with the vowel sign ृ under it. WebKit’s text shaper places that sign higher (0.27 em below the baseline) and draws Nastaliq joins at different heights. Two lines collide when the deepest mark of one meets the tallest mark of the next, so the gap between them is the line height minus the ink above and below. This program works out that gap for every script:
// How much room a line height leaves between two lines, per script. Two lines collide when the deepest mark of one
// reaches the tallest mark of the next: the gap between them is the line height minus the ink above the baseline minus
// the ink below it. The ink numbers are in em, measured with canvas measureText() on each script's tallest and deepest
// syllables in the Noto font named, the largest value of Chromium 153, Firefox 155 and WebKit 26.6 (ink-extents.html).
// "normal" is the line box the font itself asks for (its ascent plus descent), which line-height: normal uses.
const SCRIPTS = [
// script, font, ink above the baseline, ink below it, the font's normal line box (all in em)
['Latin', 'Noto Sans', 0.94, 0.24, 1.36],
['Devanagari', 'Noto Sans Devanagari', 0.9, 0.39, 1.31],
['Bengali', 'Noto Sans Bengali', 0.92, 0.27, 1.33],
['Gujarati', 'Noto Sans Gujarati', 0.9, 0.27, 1.31],
['Gurmukhi', 'Noto Sans Gurmukhi', 0.9, 0.29, 1.31],
['Odia', 'Noto Sans Oriya', 0.86, 0.26, 1.36],
['Tamil', 'Noto Sans Tamil', 0.84, 0.32, 1.24],
['Telugu', 'Noto Sans Telugu', 0.87, 0.47, 1.35],
['Kannada', 'Noto Sans Kannada', 0.79, 0.46, 1.35],
['Malayalam', 'Noto Sans Malayalam', 0.84, 0.32, 1.24],
['Urdu (Nastaliq)', 'Noto Nastaliq Urdu', 1.62, 0.46, 2.5],
];
const KEEP = 0.15; // the gap to keep between the ink of two lines, in em: 2.4 px at a 16 px font size
const gap = (lineHeight, above, below) => lineHeight - above - below;
const roundUp = (x) => Math.ceil(x * 20 - 1e-9) / 20; // to the next 0.05
const f = (x) => x.toFixed(2).padStart(6);
console.log('script ink normal gap at normal gap at 1.5 needs');
for (const [script, , above, below, normal] of SCRIPTS) {
const ink = above + below;
const needs = roundUp(ink + KEEP);
console.log(`${script.padEnd(15)}${f(ink)}${f(normal)}${f(gap(normal, above, below)).padStart(15)}${f(gap(1.5, above, below)).padStart(12)}${f(needs)}`);
}
console.log('\nAll values in em. "needs": the smallest line height, in steps of');
console.log(`0.05, that keeps ${KEEP} em between the deepest mark of one line and`);
console.log('the tallest mark of the next. A negative gap: the marks overlap.');
// A paragraph that mixes scripts needs the line height of its tallest one.
const mixed = ['Latin', 'Devanagari', 'Urdu (Nastaliq)'].map((name) => SCRIPTS.find((s) => s[0] === name));
const needed = Math.max(...mixed.map(([, , above, below]) => roundUp(above + below + KEEP)));
console.log(`\nEnglish, Hindi and Urdu in one paragraph: line-height ${needed.toFixed(2)}`);
console.log(`(${(needed * 16).toFixed(1)} px at a 16 px font size)`); Output
script ink normal gap at normal gap at 1.5 needs Latin 1.18 1.36 0.18 0.32 1.35 Devanagari 1.29 1.31 0.02 0.21 1.45 Bengali 1.19 1.33 0.14 0.31 1.35 Gujarati 1.17 1.31 0.14 0.33 1.35 Gurmukhi 1.19 1.31 0.12 0.31 1.35 Odia 1.12 1.36 0.24 0.38 1.30 Tamil 1.16 1.24 0.08 0.34 1.35 Telugu 1.34 1.35 0.01 0.16 1.50 Kannada 1.25 1.35 0.10 0.25 1.40 Malayalam 1.16 1.24 0.08 0.34 1.35 Urdu (Nastaliq) 2.08 2.50 0.42 -0.58 2.25 All values in em. "needs": the smallest line height, in steps of 0.05, that keeps 0.15 em between the deepest mark of one line and the tallest mark of the next. A negative gap: the marks overlap. English, Hindi and Urdu in one paragraph: line-height 2.25 (36.0 px at a 16 px font size)
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node line_clearance.mjs
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
How much room two lines of Hindi leave between their marks, drawn to scale
Text description of the diagram
The diagram draws two lines of text set in Noto Sans Devanagari twice, to scale, with the measurements from the lesson. Each line has three bands: a band for vowel signs above the letters, reaching 0.90 em above the baseline; the band of the letter bodies, which hang from the headline drawn as a thick line at 0.62 em; and a band for vowel signs below the letters and stacked consonants, reaching 0.39 em under the baseline.
- On the left, the lines are set with line-height: normal, which for this font is 1.31 em. The marks below the first line end 0.02 em above the marks of the second line, so they almost touch.
- On the right, the lines are set with line-height: 1.75. The gap between the lowest marks of the first line and the highest marks of the second is 0.46 em.
The numbers explain what a screenshot of a deliberately bad pair of lines showed in all three engines: a first line with marks below its letters, a second with marks above them.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Line height for Hindi and Urdu: the worst pair of lines</title>
<style>
/* Self-hosted Noto fonts (SIL Open Font License 1.1) from notofonts.github.io, served from /fonts/. */
@font-face { font-family: "Noto Sans Devanagari"; src: url("/fonts/NotoSansDevanagari-Regular.ttf"); }
@font-face { font-family: "Noto Nastaliq Urdu"; src: url("/fonts/NotoNastaliqUrdu-Regular.ttf"); }
body { margin: 16px; display: grid; grid-template-columns: repeat(3, max-content); gap: 16px 40px; font-size: 24px; }
p { margin: 0; }
:lang(hi) { font-family: "Noto Sans Devanagari", sans-serif; }
:lang(ur) { font-family: "Noto Nastaliq Urdu", serif; }
.normal { line-height: normal; }
.lh-1-5 { line-height: 1.5; }
.lh-1-75 { line-height: 1.75; }
.lh-2-3 { line-height: 2.3; }
</style>
</head>
<body>
<!-- The first line has marks below the letters, the second has marks above them. -->
<p lang="hi" class="normal" id="hi-normal">कृपया उद्धृत अंश देखें<br>कीर्ति की किताबें</p>
<p lang="hi" class="lh-1-5" id="hi-1.5">कृपया उद्धृत अंश देखें<br>कीर्ति की किताबें</p>
<p lang="hi" class="lh-1-75" id="hi-1.75">कृपया उद्धृत अंश देखें<br>कीर्ति की किताबें</p>
<p lang="ur" dir="rtl" class="lh-1-5" id="ur-1.5">یہ سب سے اچھی بات ہے<br>نستعلیق کتابت کا فن</p>
<p lang="ur" dir="rtl" class="normal" id="ur-normal">یہ سب سے اچھی بات ہے<br>نستعلیق کتابت کا فن</p>
<p lang="ur" dir="rtl" class="lh-2-3" id="ur-2.3">یہ سب سے اچھی بات ہے<br>نستعلیق کتابت کا فن</p>
<script>
// Measure once the web fonts are in: the distance between the two lines is the used line height.
document.fonts.ready.then(() => {
for (const p of document.querySelectorAll('p')) {
const [first, second] = [...p.childNodes].filter((n) => n.nodeType === Node.TEXT_NODE).map((n) => {
const range = document.createRange();
range.selectNodeContents(n);
return range.getBoundingClientRect().top;
});
const step = Math.round(second - first);
console.log(`${p.id}: lines ${step} px apart at a font size of 24 px (${(step / 24).toFixed(2)} em)`);
}
});
</script>
</body>
</html> - Hindi with
line-height: normal: the vocalic r under द्ध almost touched the र् mark of the next line. Noto Sans Devanagari’s line box is only 0.02 em taller than its ink. - Hindi at 1.5: a thin gap. At 1.75: clear.
- Urdu in Nastaliq at 1.5: the lines ran into each other. At 2.3, and at the font’s own
normalof 2.5, they were clear.
So set the line height per script, with :lang(). The program’s minimums keep marks apart; body text reads better with
more, around 1.6 to 1.75 for Devanagari and the other Indic scripts, a little more for Telugu, whose stacked forms reach
almost half an em below the baseline, and at least 2.2 for Urdu in Nastaliq. A paragraph that mixes English and Hindi
needs the Hindi value, because one line box holds both.
Underlines that don’t cut vowel signs
The default underline sits close to the baseline, right where ु, ू and ृ hang. A link in Hindi with four underline settings showed the same thing in all three engines:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Underlined Hindi links</title>
<style>
/* Self-hosted Noto fonts (SIL Open Font License 1.1) from notofonts.github.io, served from /fonts/. */
@font-face { font-family: "Noto Sans Devanagari"; src: url("/fonts/NotoSansDevanagari-Regular.ttf"); }
body { margin: 16px; font: 28px/1.8 "Noto Sans Devanagari", sans-serif; }
a { color: #1d4ed8; }
.no-skip { text-decoration-skip-ink: none; }
.offset { text-underline-offset: 0.4em; }
.under { text-underline-position: under; }
</style>
</head>
<body lang="hi">
<p><a href="#" id="default">कुछ ज़रूरी सूचनाएँ</a> (the default)</p>
<p><a href="#" class="no-skip" id="no-skip">कुछ ज़रूरी सूचनाएँ</a> (skip-ink: none)</p>
<p><a href="#" class="offset" id="offset">कुछ ज़रूरी सूचनाएँ</a> (offset 0.4em)</p>
<p><a href="#" class="under" id="under">कुछ ज़रूरी सूचनाएँ</a> (position: under)</p>
<script>
for (const a of document.querySelectorAll('a')) {
const cs = getComputedStyle(a);
console.log(`${a.id}: skip-ink ${cs.textDecorationSkipInk}, offset ${cs.textUnderlineOffset}, position ${cs.textUnderlinePosition}`);
}
</script>
</body>
</html> | Setting | What the screenshots showed |
|---|---|
The default (text-decoration-skip-ink: auto) |
the line stops and starts around each vowel sign below, so it looks broken |
text-decoration-skip-ink: none |
the line runs straight through the vowel signs |
text-underline-offset: 0.4em |
one clean line below every mark |
text-underline-position: under |
one clean line below every mark |
text-underline-offset and text-underline-position: under are Baseline widely available, and CSS Snapshot 2026
clears the offset for broad use. Set one of them per language, for example :lang(hi) a { text-underline-offset: 0.35em; }; for Telugu and Kannada use the larger offset their deeper stacks need, or under.
Drop caps and letter-spacing: the unit is a syllable
CSS treats an extended grapheme cluster, Unicode’s user-perceived character, as the basic unit of text, and asks browsers to tailor it to each script’s typographic tradition. Since Unicode 15.1, rule GB9c keeps consonants joined by a virama in one cluster, so in Devanagari or Bengali a cluster is usually a whole orthographic syllable. The W3C’s Indic Layout Requirements ask for exactly that unit in drop caps and in letter spacing. This program shows the difference between code points and clusters, and what the older rule did:
// What counts as a "letter" in Indian scripts. A word is a list of code points; Intl.Segmenter groups them into
// extended grapheme clusters (Unicode's "user-perceived characters"), which since Unicode 15.1 keep a consonant cluster
// joined by a virama together (rule GB9c), so a cluster here is the whole orthographic syllable. The older rule broke
// after every virama; the last column shows where.
const WORDS = [
['Latin', 'school'],
['Devanagari', 'स्कूल'], // school
['Devanagari', 'नमस्ते'], // greeting
['Devanagari', 'स्वागतम्'], // welcome
['Bengali', 'স্কুল'], // school
['Tamil', 'கொடி'], // flag
['Tamil', 'தமிழ்'], // Tamil
];
const codePoints = (word) => [...word].map((ch) => 'U+' + ch.codePointAt(0).toString(16).toUpperCase().padStart(4, '0'));
const segmenter = new Intl.Segmenter('und', { granularity: 'grapheme' });
const clusters = (word) => [...segmenter.segment(word)].map((s) => s.segment);
// Without rule GB9c: a cluster also ends after a virama (U+094D Devanagari, U+09CD Bengali) that a letter follows.
const legacy = (word) => clusters(word).flatMap((c) => c.replace(/([\u094D\u09CD])(?=\p{L})/gu, '$1|').split('|'));
for (const [script, word] of WORDS) {
const c = clusters(word);
console.log(`${script} ${word}`);
console.log(` ${[...word].length} code points (${codePoints(word).join(' ')}), UTF-16 length ${word.length}`);
console.log(` ${c.length} clusters: ${c.join(' | ')}`);
const old = legacy(word);
if (old.length !== c.length) console.log(` without GB9c: ${old.length} clusters: ${old.join(' | ')}`);
} Output
Latin school 6 code points (U+0073 U+0063 U+0068 U+006F U+006F U+006C), UTF-16 length 6 6 clusters: s | c | h | o | o | l Devanagari स्कूल 5 code points (U+0938 U+094D U+0915 U+0942 U+0932), UTF-16 length 5 2 clusters: स्कू | ल without GB9c: 3 clusters: स् | कू | ल Devanagari नमस्ते 6 code points (U+0928 U+092E U+0938 U+094D U+0924 U+0947), UTF-16 length 6 3 clusters: न | म | स्ते without GB9c: 4 clusters: न | म | स् | ते Devanagari स्वागतम् 8 code points (U+0938 U+094D U+0935 U+093E U+0917 U+0924 U+092E U+094D), UTF-16 length 8 4 clusters: स्वा | ग | त | म् without GB9c: 5 clusters: स् | वा | ग | त | म् Bengali স্কুল 5 code points (U+09B8 U+09CD U+0995 U+09C1 U+09B2), UTF-16 length 5 2 clusters: স্কু | ল without GB9c: 3 clusters: স্ | কু | ল Tamil கொடி 4 code points (U+0B95 U+0BCA U+0B9F U+0BBF), UTF-16 length 4 2 clusters: கொ | டி Tamil தமிழ் 5 code points (U+0BA4 U+0BAE U+0BBF U+0BB4 U+0BCD), UTF-16 length 5 3 clusters: த | மி | ழ்
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node syllables.mjs
Intl.Segmenter gave the same clusters in all three engines. Drop caps and letter-spacing then went their own ways:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Drop caps and letter-spacing on Indian scripts</title>
<style>
/* Self-hosted Noto fonts (SIL Open Font License 1.1) from notofonts.github.io, served from /fonts/. */
@font-face { font-family: "Noto Sans Devanagari"; src: url("/fonts/NotoSansDevanagari-Regular.ttf"); }
@font-face { font-family: "Noto Sans Bengali"; src: url("/fonts/NotoSansBengali-Regular.ttf"); }
@font-face { font-family: "Noto Sans Tamil"; src: url("/fonts/NotoSansTamil-Regular.ttf"); }
body { margin: 16px; font-size: 32px; line-height: 1.6; }
:lang(hi) { font-family: "Noto Sans Devanagari", sans-serif; }
:lang(bn) { font-family: "Noto Sans Bengali", sans-serif; }
:lang(ta) { font-family: "Noto Sans Tamil", sans-serif; }
p { margin: 0 0 8px; }
.drop::first-letter { font-size: 2em; color: #b91c1c; }
.tracked { letter-spacing: 0.3em; }
</style>
</head>
<body>
<p class="drop" lang="hi">स्कूल खुल गया</p>
<p class="drop" lang="bn">স্কুল খুলেছে</p>
<p class="drop" lang="ta">கொடி ஏற்றம்</p>
<p class="tracked" lang="hi">स्कूल</p>
<p class="tracked" lang="hi">स्वागतम्</p>
<p class="tracked" lang="bn">বাংলা</p>
<p class="tracked" lang="ta">கொடி</p>
<p class="tracked" lang="en">school</p>
<script>
// How many times each engine added the spacing: the extra width divided by one spacing.
document.fonts.ready.then(() => {
for (const p of document.querySelectorAll('.tracked')) {
const width = (spacing) => {
const s = document.createElement('span');
s.textContent = p.textContent;
s.style.letterSpacing = spacing;
p.append(s);
const w = s.getBoundingClientRect().width;
s.remove();
return w;
};
const unit = parseFloat(getComputedStyle(p).letterSpacing);
const times = (width(`${unit}px`) - width('0px')) / unit;
console.log(`${p.firstChild.data}: letter-spacing added ${Math.round(times)} times`);
}
});
</script>
</body>
</html> Drop caps work. ::first-letter took the whole first syllable in all three engines:
स्कू of स्कूल, স্কু of
স্কুল, and the Tamil கொ, whose vowel sign sits on both sides of the
consonant. initial-letter, which would size and sink the drop cap properly, is limited to Chromium-based browsers, so
use ::first-letter with a larger font-size and float: inline-start.
Letter-spacing does not. CSS Text inserts the spacing between typographic character units, and it counts Indic
scripts whose letters join along a line, such as Devanagari and Gujarati, as scripts that may be spaced, unlike cursive
scripts such as the Arabic script of Urdu, which authors should not space at all. The measurements tell another story.
The example sets each word with and without its letter-spacing and divides the difference in width by the spacing,
which gives how many times each engine added it (Chromium, Firefox, WebKit):
- school, 6 letters: 6, 6 and 6.
- स्कूल, 2 clusters: 3, 2 and 3.
- स्वागतम्, 4 clusters: 5, 4 and 6.
- বাংলা, 2 clusters: 2, 2 and 5.
- கொடி, 2 clusters: 2, 2 and 4.
Firefox spaced syllables, as the Indic Layout Requirements ask, and broke the headline only between them. Chromium’s counts match the clusters of the older rule, so it opened a gap inside the conjunct स्व and showed a bare virama. WebKit put space between vowel signs and their consonants, which tore கொ apart. So don’t letter-space Indian scripts. For a spaced-out heading, wrap each syllable in an element and space the elements, as the vertical sign in the previous lesson did.
Numbers and lists in Indian digits
The predefined counter styles devanagari, bengali, gujarati, gurmukhi, kannada, malayalam, oriya, tamil
and telugu number a list in each script’s own digits, and persian gives the Extended Arabic-Indic digits that Urdu
uses. They are Baseline widely available. Chromium’s accessibility tree, which assistive technologies read, recorded
these markers, and Firefox and WebKit drew the same:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Lists numbered in Indian digits</title>
<style>
/* Self-hosted Noto fonts (SIL Open Font License 1.1) from notofonts.github.io, served from /fonts/. */
@font-face { font-family: "Noto Sans Devanagari"; src: url("/fonts/NotoSansDevanagari-Regular.ttf"); }
@font-face { font-family: "Noto Sans Bengali"; src: url("/fonts/NotoSansBengali-Regular.ttf"); }
@font-face { font-family: "Noto Sans Tamil"; src: url("/fonts/NotoSansTamil-Regular.ttf"); }
@font-face { font-family: "Noto Nastaliq Urdu"; src: url("/fonts/NotoNastaliqUrdu-Regular.ttf"); }
body { margin: 16px; font-size: 18px; display: flex; flex-wrap: wrap; gap: 8px 48px; }
:lang(hi) { font-family: "Noto Sans Devanagari", sans-serif; }
:lang(bn) { font-family: "Noto Sans Bengali", sans-serif; }
:lang(ta) { font-family: "Noto Sans Tamil", sans-serif; }
:lang(ur) { font-family: "Noto Nastaliq Urdu", serif; line-height: 2.3; }
ol:lang(hi) { list-style-type: devanagari; }
ol:lang(bn) { list-style-type: bengali; }
ol:lang(ta) { list-style-type: tamil; }
ol:lang(ur) { list-style-type: persian; } /* the Extended Arabic-Indic digits Urdu uses */
/* A custom format: (१), (२), (३) … built on the predefined Devanagari digits. */
@counter-style devanagari-paren { system: extends devanagari; prefix: "("; suffix: ") "; }
ol.paren { list-style-type: devanagari-paren; }
</style>
</head>
<body>
<ol lang="hi" start="9"><li>नौ</li><li>दस</li><li>ग्यारह</li></ol>
<ol lang="bn" start="9"><li>নয়</li><li>দশ</li><li>এগারো</li></ol>
<ol lang="ta" start="9"><li>ஒன்பது</li><li>பத்து</li><li>பதினொன்று</li></ol>
<ol lang="ur" dir="rtl" start="9"><li>نو</li><li>دس</li><li>گیارہ</li></ol>
<ol lang="hi" class="paren"><li>पहला</li><li>दूसरा</li></ol>
</body>
</html> | Style | Markers for 9, 10, 11 |
|---|---|
devanagari |
९. १०. ११. |
bengali |
৯. ১০. ১১. |
tamil |
௯. ௧௦. ௧௧. |
persian, in an Urdu list |
۹. ۱۰. ۱۱. |
These are numeric styles: ten is written one-zero in the script’s digits, so the Tamil ten is
௧௦, not the traditional Tamil sign for ten. For another format, extend a style with
@counter-style, which is Baseline widely available: system: extends devanagari; prefix: "("; suffix: ") "; gives
(१) (२). A counter style changes list markers and counter() output only; digits typed in the
text stay as they are, so use the digits your content uses.
Practise: list markers in Indian digits
Exercise · Easy · JavaScript
Write list markers in Indian digits
list-style-type: devanagari, bengali, tamil and the other Indian counter styles are numeric systems: the number is written with the script's own ten digits, place by place, so ten in Tamil is ௧௦ (one, zero). Write marker(n, style, options) in markers.mjs and export it. It returns the text a list item shows for the counter value n, which is an integer:
- style is one of 'decimal' (the default), 'devanagari', 'bengali', 'gurmukhi', 'gujarati', 'oriya', 'tamil', 'telugu', 'kannada', 'malayalam' and 'persian' (the Extended Arabic-Indic digits that Urdu uses). Each script's digits zero to nine are consecutive code points starting at its zero: U+0030 for decimal, U+0966 Devanagari, U+09E6 Bengali, U+0A66 Gurmukhi, U+0AE6 Gujarati, U+0B66 Odia, U+0BE6 Tamil, U+0C66 Telugu, U+0CE6 Kannada, U+0D66 Malayalam and U+06F0 for persian. - Any other style name falls back to decimal, as a counter style that does not exist does in CSS. - A negative value gets a hyphen-minus - in front of its digits. - options may give a prefix (default: nothing) and a suffix (default: a full stop and a space, '. '), the two descriptors you would set in an @counter-style rule. The marker is the prefix, then the sign, the digits and the suffix.
So marker(10, 'devanagari') is '१०. ' and marker(1, 'devanagari', { prefix: '(', suffix: ') ' }) is '(१) '. The sample tests import marker from markers.mjs and run in your browser.
Starter code · markers.mjs
/** The marker text a list item shows for counter value n in a numeric counter style. */
export function marker(n, style = 'decimal', { prefix = '', suffix = '. ' } = {}) {
// Replace this line with your code.
return `${prefix}${n}${suffix}`;
} The sample tests · markers.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { marker } from './markers.mjs';
test('the markers the lesson recorded', () => {
assert.equal(marker(9, 'devanagari'), '९. ');
assert.equal(marker(10, 'devanagari'), '१०. ');
assert.equal(marker(11, 'bengali'), '১১. ');
assert.equal(marker(10, 'tamil'), '௧௦. ');
});
test('every digit and zero', () => {
assert.equal(marker(1234567890, 'gujarati'), '૧૨૩૪૫૬૭૮૯૦. ');
assert.equal(marker(0, 'tamil'), '௦. ');
assert.equal(marker(12, 'persian'), '۱۲. ');
assert.equal(marker(405, 'telugu'), '౪౦౫. ');
});
test('decimal, negative values and unknown styles', () => {
assert.equal(marker(25), '25. ');
assert.equal(marker(-3, 'kannada'), '-೩. ');
assert.equal(marker(7, 'klingon'), '7. ');
});
test('a prefix and a suffix, as in @counter-style', () => {
assert.equal(marker(1, 'devanagari', { prefix: '(', suffix: ') ' }), '(१) ');
assert.equal(marker(2, 'malayalam', { suffix: ' - ' }), '൨ - ');
}); A hint
Each script's ten digits sit next to each other in Unicode, in order from zero: Devanagari ० is U+0966 and ९ is U+096F. Keep a table of the zero of each style, then replace every ASCII digit of String(Math.abs(n)) with String.fromCodePoint(zero + Number(digit)).
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (QuickJS): nothing is sent to mysmartcopilot.com. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Check yourself
6 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.
Interview questions
Fresher · Why should a Hindi page set lang=“hi” when the text already shows correctly without it?
Because lang changes more than whether the letters appear. It tells browsers which font and which letter shapes to
use: in the same font, lang="mr" draws श in its Marathi form and lang="ne" draws
झ in its Nepali form, and with no font named WebKit even picks a different font for Urdu.
Screen readers choose their voice and pronunciation from it, hyphens: auto needs it to find a dictionary, and search
engines and translation tools read it. It is also the hook for CSS: :lang(hi) can give Hindi passages their own font
stack, a line height of about 1.75 and a lower underline, without classes. WCAG 2.2 asks for it too: the page’s
language at level A, and each passage in another language at level AA.
Key takeaways
- Tag every passage with
lang(hi,mr,ta,ur…): it picks fonts and letter forms, drives screen readers and hyphenation, and lets:lang()style each script; write:lang(hi), :lang(mr), not a list inside one:lang(). - Build a stack per script: a self-hosted OFL font split by
unicode-range, then Nirmala UI and the Apple families, then a generic family. - Give marks room: Devanagari ink spans 1.29 em but its font’s own line box is 1.31 em, so set about 1.6 to 1.75 for Indic scripts and at least 2.2 for Urdu in Nastaliq.
- Lower link underlines with
text-underline-offsetortext-underline-position: under, so they don’t cut the vowel signs below the letters. ::first-lettertakes the whole first syllable in current engines; letter-spacing does not respect syllables in Chromium or WebKit, so don’t track Indian scripts.- Number lists with
devanagari,bengali,tamiland the other predefined styles, or extend them with@counter-style.
References
- HTML Standard: the lang attribute (WHATWG)
- Why use the language attribute? (W3C Internationalization)
- Web Content Accessibility Guidelines (WCAG) 2.2: Language of Page and Language of Parts (W3C)
- Language tags in HTML and XML (W3C Internationalization)
- Selectors Level 4: the language pseudo-class :lang() (W3C CSS Working Group)
- CSS Text Module Level 3: typographic character units (W3C CSS Working Group)
- CSS Text Module Level 3: letter-spacing and cursive scripts (W3C CSS Working Group)
- Indic Layout Requirements (W3C)
- Unicode Text Segmentation (UAX #29): grapheme cluster rule GB9c (Unicode Consortium)
- CSS Pseudo-Elements Level 4: ::first-letter (W3C CSS Working Group)
- CSS Text Decoration Level 4: text-underline-offset (W3C CSS Working Group)
- CSS Counter Styles Level 3: predefined numeric styles (W3C CSS Working Group)
- CSS Fonts Module Level 4: unicode-range (W3C CSS Working Group)
- Nirmala UI font family (Microsoft)
- Fonts included with macOS Sequoia (Apple)
- Noto Sans Devanagari (SIL Open Font License 1.1) (Noto project)
- Guidelines for Indian Government Websites and Apps (GIGW 3.0) (National Informatics Centre, Government of India)
- list-style (Baseline, web-features explorer) (W3C WebDX Community Group)
- :lang() (Baseline, web-features explorer) (W3C WebDX Community Group)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress