@arag 0.1.0

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.

Latin source strings and their Mongolian Cyrillic equivalents. Counts are characters, not rendered width.
English Mongolian Latin Cyrillic Δ
Create a new orderШинэ захиалга үүсгэх1820+11%
User settingsХэрэглэгчийн тохиргоо1321+62%
Payment details were updated successfully.Төлбөрийн мэдээлэл амжилттай шинэчлэгдлээ.42420%
Account balanceДансны үлдэгдэл15150%
Transfer confirmedГуйвуулга баталгаажлаа1822+22%
Welcome backДахин тавтай морил1218+50%
Updated 2 minutes ago2 минутын өмнө шинэчлэгдсэн2127+29%
Today is cloudy with a gentle breeze.Өнөөдөр үүлэрхэг, зөөлөн салхитай.3734−8%
Phone numberУтасны дугаар1213+8%
SendИлгээх46+50%
LightГэрэлтэй58+60%
DarkХаранхуй48+100%
All twelve, combined201234+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.

Re-measure, do not assume

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?

Prohibited patterns and the concrete failure each one produces.
Do notWhat breaksInstead
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.
The one that costs the most

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.css

Logical 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

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.

The same paragraph at --leading-tight and at --leading-normal.
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:

If you ever add a webfont

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>
Get the tag right

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.