Bilingual & Cyrillic
Every string in these products ships in Latin and in Mongolian Cyrillic. That one
fact is why the system has no fixed heights, no nowrap, no uppercase transforms
and a generous default leading. This page is the reasoning, with the measurements.
The constraint
Mongolian Cyrillic runs roughly 15–25% longer than the equivalent Latin string for the same
meaning. Not occasionally — as a rule, across UI labels, running copy and system messages
alike. Every layout decision in @arag is downstream of that number.
Here are the strings this documentation set uses, side by side. The character counts are a rough proxy only; read the note under the table before treating them as the budget.
| English | Mongolian | Latin | Cyrillic | Δ |
|---|---|---|---|---|
| Create a new order | Шинэ захиалга үүсгэх | 18 | 20 | +11% |
| User settings | Хэрэглэгчийн тохиргоо | 13 | 21 | +62% |
| Payment details were updated successfully. | Төлбөрийн мэдээлэл амжилттай шинэчлэгдлээ. | 42 | 42 | 0% |
| Account balance | Дансны үлдэгдэл | 15 | 15 | 0% |
| Transfer confirmed | Гуйвуулга баталгаажлаа | 18 | 22 | +22% |
| Welcome back | Дахин тавтай морил | 12 | 18 | +50% |
| Updated 2 minutes ago | 2 минутын өмнө шинэчлэгдсэн | 21 | 27 | +29% |
| Today is cloudy with a gentle breeze. | Өнөөдөр үүлэрхэг, зөөлөн салхитай. | 37 | 34 | −8% |
| Phone number | Утасны дугаар | 12 | 13 | +8% |
| Send | Илгээх | 4 | 6 | +50% |
| Light | Гэрэлтэй | 5 | 8 | +60% |
| Dark | Харанхуй | 4 | 8 | +100% |
| All twelve, combined | — | 201 | 234 | +16.4% |
The measured figure
Character count is the wrong unit and the table shows why: Payment details were updated successfully. and its Mongolian equivalent are both 42 characters, and short button labels swing wildly in both directions on a sample of four characters. What decides whether a layout breaks is rendered width.
Measured on the system font stack at , that same
sentence pair rendered 14.3% wider in Cyrillic. That is a real number, taken
from a real render, and it lands slightly under the 15–25% budget — the budget is
deliberately pessimistic, and one sentence is not a corpus.
14.3% is specific to this font stack, at this size, on the platform it was measured on.
system-ui resolves to a different face on Android, on Windows and on the kiosk
image, and the widths follow the face. Re-run the measurement on the Android target and on
the kiosk before signing off a dense layout.
The pair below is measured live, in whatever browser and OS you opened this page in, rather than printed as a constant:
Payment details were updated successfully.
Төлбөрийн мэдээлэл амжилттай шинэчлэгдлээ.
enable scripting to measure rendered widths
What that forbids
Each of these is a rule with a failure mode attached, not a style preference. The test to apply is always the same: what does this do when the label is 22% longer than the English one you designed against?
| Do not | What breaks | Instead |
|---|---|---|
Set a fixed width or height on anything holding text |
The box was sized to Send. Илгээх is 50% longer and either overflows the border or gets clipped by it. A fixed height fails the same way the moment the label wraps to two lines: the second line is cut off mid-glyph. |
min-block-size plus padding, so the control grows to its label. An inline-size ceiling via max-inline-size is fine; a floor that is also a ceiling is not. |
Use white-space: nowrap on text |
The English label fits on one line and the Mongolian one does not, so instead of wrapping it forces its container wider. In a table that pushes a column off-screen; in a flex row it squeezes every sibling; in a card grid it breaks the grid. The damage always lands somewhere other than the element you set it on, which is what makes it hard to find. | Let it wrap. Reserve nowrap for things that genuinely cannot break — a currency amount, a phone number, a token name. |
| Assume one line in nav, cards, tables or buttons | A nav item designed as a single 40px row becomes two lines, and every rule that assumed 40px — the sibling alignment, the icon centring, the container height, the active-state border — is now wrong. Cards in a row stop having equal heights. A button's icon and label stop being on the same baseline. | Design the two-line case first. Use align-items rather than fixed offsets, and let the row height come from the tallest item. |
Apply text-transform: uppercase |
Cyrillic uppercase is materially wider than its lowercase — the letterforms are wider, not merely taller — so an uppercase transform spends the length budget twice over on the same string. It is usually applied to the smallest text in a design, which has the least room to absorb it. It also degrades legibility for readers of the script and mangles screen-reader pronunciation of abbreviations. | Weight, colour, letter-spacing (--tracking-wide) or size. If a label must look like a label, make it look like one without changing its glyphs. |
| Truncate with an ellipsis as a layout strategy | Truncation hides the failure instead of fixing it. Because Cyrillic is longer, the truncation point lands earlier in the meaning, so the Mongolian user loses information the English user keeps — and the layout looks fine in review, which is worse than looking broken. | Wrap, or use fewer words in both languages. Truncation is acceptable only for genuinely unbounded user-supplied content — a filename, a pasted URL — never for translated UI strings. |
Fixed dimensions are the expensive mistake, because they are invisible until translation and
then they are everywhere at once. A component with height: 40px is a component
that has to be rewritten, not adjusted. Grep for width: and height:
in any new component file before it lands — there is no legitimate reason for either to
appear on a text-bearing element in this system.
What the system already does about it
These are not aspirations. Each rule below is in the package today and exists for this reason.
reset.css — the safety net
Every text-bearing element gets a hard guard against overflow, regardless of what a component later does to it. This is in the lowest layer on purpose: a component can improve on it, but nothing has to remember to add it.
/* Bilingual (Latin + Mongolian Cyrillic) content runs 15-25% longer in
Cyrillic than in Latin for the same meaning, and neither script can be
assumed to fit on one line at any given inline-size. Every text-bearing
element gets a hard safety net against overflow. */
p, h1, h2, h3, h4, h5, h6,
li, dt, dd, figcaption, blockquote, th, td {
overflow-wrap: break-word;
}
p {
text-wrap: pretty;
}
h1, h2, h3, h4, h5, h6 {
text-wrap: balance;
}overflow-wrap: break-word means a long unbroken Cyrillic compound breaks rather
than blowing out its container. text-wrap: balance on headings matters more here
than in a monolingual system: a heading that is one line in English and two in Mongolian gets a
sensible split instead of a single orphaned word. text-wrap: pretty does the same
job for the last line of a paragraph.
textarea:not([rows]) {
min-block-size: 10em;
}Note the em unit and the min- prefix. Even in the token-free reset
layer, the free-text field is given room rather than a height.
base.css — controls size to content
Not one form control in the system has a height or a block-size.
Three declarations cover every case:
input:not([type="checkbox"], [type="radio"], …), textarea, select {
/* min-block-size, not a fixed height: Cyrillic labels/placeholders run
longer and controls must be free to grow to fit their content. */
min-block-size: var(--size-control);
}
button, input[type="button"], input[type="submit"], input[type="reset"] {
/* min-block-size at the touch target size, not a fixed height: this is
tappable and Cyrillic labels are longer, so it must size to content
in both directions. */
min-block-size: var(--size-touch);
}
textarea:not([rows]) {
min-block-size: var(--size-textarea);
}The resolved values: for a text input,
for anything tappable, and
for a free-text field. That last one is not a round
number chosen for looks — tokens.css says it plainly: a free-text field must
not be the height of a single-line input; Cyrillic runs 15-25% longer, so the box that absorbs
that needs real room. The reset's 10em would otherwise have been overridden
down to --size-control by the shared control rule in the later layer, leaving a
two-line box for a Mongolian free-text field. It is restated in base so it wins,
in the same layer, without raising priority.
Table cells wrap, and it is called out where it happens:
th, td {
/* No nowrap: Mongolian Cyrillic headers and cell content run longer
than their Latin source strings and must be free to wrap. */
padding-block: var(--space-3);
padding-inline: var(--space-4);
border: var(--border-width-1) solid var(--color-border-subtle);
text-align: start;
}And h6 is deliberately not uppercased, which is the one place in a type scale
where the temptation is strongest:
/* No uppercase transform here: Mongolian Cyrillic uppercase runs
noticeably wider than lowercase, which would blow the length budget on
an element already at the smallest heading step. */
h6 {
font-size: var(--text-sm);
font-weight: var(--weight-medium);
line-height: var(--leading-snug);
}There is no text-transform anywhere in the package. That is greppable, and it
holds today.
# must return nothing
grep -rn 'text-transform' src/
# must return nothing
grep -rnE '^\s*(width|height|block-size|inline-size)\s*:' src/base.cssLogical properties throughout
The whole package is written in logical properties. inline-size and
block-size rather than width and height;
margin-block and padding-inline rather than
margin-top and padding-left; border-inline-start rather
than border-left; inset-block-start rather than top.
Even the fluid type scale uses vi rather than vw.
Two reasons, and the first is the one that matters day to day.
It is the same discipline that makes "size to content" natural. Writing
min-block-size forces you to answer which axis is the text flowing along, and
which one is it stacking along, and once that question is in front of you the correct
answer — a minimum on the stacking axis, a maximum on the flow axis — is obvious. Writing
height: 40px asks you nothing, which is exactly why it is easy to get wrong.
Logical properties make the length constraint visible in the syntax.
The second is that it keeps the door open. Mongolian is set horizontally in Cyrillic, so nothing in these products needs a vertical writing mode today. But traditional Mongolian script is vertical, and a system built on physical properties would need rewriting rather than configuring if that ever came up. The cost of the discipline is zero; the cost of not having it is a rewrite.
Line height
Cyrillic sets taller than Latin at the same font size. The x-height is proportionally larger,
ascenders and descenders are shorter, and the result is a denser block of text — so a leading
that reads comfortably in English can feel cramped in Mongolian at the identical
line-height value.
--leading-normal is therefore deliberately generous at
, and it is what body uses. The tighter
steps have narrower jobs: for small headings and
dense UI, and for display headings
only.
Do not put --leading-tight on running Cyrillic. It is sized for one or two
words at --text-2xl or --text-3xl, where the lines are far apart in
absolute terms even at a 1.2 multiplier. On a paragraph at --text-base the same
multiplier closes the lines up to the point where the reader loses their place returning to
the start of the next one.
The comparison below is the same content in both scripts, at both leadings, on this page.
| Leading | Latin | Mongolian Cyrillic |
|---|---|---|
--leading-tight |
Cyrillic sets taller than Latin at the same size, so a leading that reads comfortably in English can feel cramped in Mongolian. Compare this against the row below. |
Кирилл үсэг латинаас өндөр тогтдог тул англи хэл дээр тохиромжтой мөрийн зай монгол хэл дээр давчуу санагдаж болно. Доод мөртэй харьцуулж шалгана уу. |
--leading-normal |
Cyrillic sets taller than Latin at the same size, so a leading that reads comfortably in English can feel cramped in Mongolian. Compare this against the row above. |
Кирилл үсэг латинаас өндөр тогтдог тул англи хэл дээр тохиромжтой мөрийн зай монгол хэл дээр давчуу санагдаж болно. Доод мөртэй харьцуулж шалгана уу. |
The gap between the two rows is the same in both columns in absolute terms, but it does not read that way: the Cyrillic block at the tight setting has visibly less white between lines relative to the mass of the type, because there is more type in the same band. That is the whole argument for the generous default.
Glyph coverage
ө and ү are the two letters to test. They are Mongolian Cyrillic additions to the Russian alphabet, and they are the two most often missing from a font, or present but badly hinted, or present only in the regular weight. A face can claim Cyrillic support and still fail on exactly these two.
This is the coverage-test line. It contains both letters several times, in both cases:
Өнөөдөр үүлэрхэг, зөөлөн салхитай.
Markup
<p lang="mn">Өнөөдөр үүлэрхэг, зөөлөн салхитай.</p>What a fallback looks like: the browser cannot find ө in the first face of the stack, walks to the next face that has it, and renders that one glyph from a different font. The result is a visible mismatch mid-line — a letter that is heavier or lighter than its neighbours, or wider, or sitting on a slightly different baseline, or with a different stroke contrast. Read the line above at this size and check that every ө and ү looks like it belongs to the same alphabet as the letters around it. If one jumps out, the first face in the stack is not covering it on that platform.
This is a large part of why the system uses a system font stack and downloads nothing. Every face in it carries full Mongolian Cyrillic coverage including both letters, there is no network request that can fail and leave a fallback showing, and there is no FOUT during which the page renders in a face that does not cover the script.
The stack, as your browser resolves it right now:
Test ө and ү in every weight you ship, not just regular — partial coverage in the bold face is common and shows up only on headings. Test the uppercase forms too. And keep the system stack as the fallback, because a webfont that fails to load must not fail to render the script.
Marking up language
lang="mn" on Mongolian content is not decorative and it is not for tooling. The
browser uses it for three things you can see: font fallback selection, hyphenation and line
breaking, and screen-reader voice and pronunciation. A screen reader with no
lang attribute reads Cyrillic with English phonetics, which is not merely wrong,
it is unintelligible.
For a page that is entirely in Mongolian, mark the root and nothing else:
<html lang="mn">
<body>
<h1>Дахин тавтай морил</h1>
<p>Дансны үлдэгдэл</p>
</body>
</html>For a mixed-script page, mark the root with the document's primary language and mark each subtree that differs from it:
<html lang="en">
<body>
<h1>Account balance</h1>
<section lang="mn">
<h2>Дансны үлдэгдэл</h2>
<p>2 минутын өмнө шинэчлэгдсэн</p>
</section>
</body>
</html>For an inline switch — a Mongolian label beside an English one, a single term quoted in the
other script — use a span and mark only the run that changes:
<button type="submit">Send · <span lang="mn">Илгээх</span></button>
<label for="tel">Phone number / <span lang="mn">Утасны дугаар</span></label>
<input id="tel" type="tel">The rendered result, so the pattern is concrete:
Markup
<label for="tel">Phone number / <span lang="mn">Утасны дугаар</span></label>
<input id="tel" type="tel" placeholder="+976">
<button type="button">Send · <span lang="mn">Илгээх</span></button>The subtag for Mongolian is mn. Use mn-Cyrl only if you also ship
content in the traditional vertical script and need to distinguish the two — otherwise the
plain tag is correct and less to get wrong. Never mark Mongolian as ru because
the alphabet looks similar; the pronunciation rules differ and so does font selection.
A checklist
Run this against any new component before it lands. It is short on purpose — every item is a thing that has actually broken a bilingual layout.
- Does it grow to fit a 25% longer label? Paste Хэрэглэгчийн тохиргоо in place of User settings and look at it. Not a mental exercise — actually paste it.
- Does it survive a two-line label? Narrow the viewport until something wraps. Check that siblings still align, that the icon is still on the first line's baseline, and that nothing is clipped at the bottom.
- Is any dimension fixed? Grep the file for
width:,height:,block-size:andinline-size:. Every hit needs a reason that is not "it looked right in English".min-andmax-are fine. - Is anything uppercased? Grep for
text-transform. The answer should be no hits. - Is anything
nowrap? Grep forwhite-spaceand fortext-overflow. If either appears on translated UI text rather than on a number or an identifier, it is wrong. - Does the tap target still meet
--size-touch?, measured after the label wrapped — a control that met the target in English and was then given a taller label is still fine, but one that met it only because of a fixed height is not. - Is the Mongolian marked
lang="mn"? Including the strings that come from a translation file at runtime, which is where it is usually forgotten.