Skip to content

Typography

A UI principle for coding agents. Also covers type, text, fonts, line height, readability.

Establish a Type Scale

  • Define a set of font sizes (8-10 options)
  • Use mathematical ratios or hand-pick sizes
  • Not every size needs to be used
  • Having options available speeds up decision-making
  • Spend about three sizes on any one view. The scale lists options; a screen picks a title size, a section size, and a body size, then lets weight, color, and spacing carry every other distinction. A caption or legal line can be a fourth when it is a different kind of text, not body copy shrunk to look secondary. Each size announces a level of hierarchy, so a view with six sizes claims six levels and flattens into texture instead. Check a finished screen by listing the distinct sizes and the distinct weights it renders: more than four sizes, or more than three weights, means the hierarchy is being spent rather than designed. See review-rules.md (Too many type sizes on one view).
  • Use px or rem, never em for sizes: em compounds through nested elements, producing unpredictable fractional sizes
  • Keep ~25% minimum jumps between sizes used for distinct roles (16px to 20px, not 16px to 18px). Two near-identical sizes can't create hierarchy. The scale lists options; don't pair two adjacent near-equal sizes for separate roles (e.g. a heading at 18px above body at 16px reads as one size).

Example Scale:

text-xs: 12px
text-sm: 14px
text-base: 16px
text-lg: 18px
text-xl: 20px
text-2xl: 24px
text-3xl: 30px
text-4xl: 36px
text-5xl: 48px
text-6xl: 60px
text-7xl: 72px

Use Good Fonts

Trust the professionals:

  • Good free options: Inter, Roboto, Open Sans, Lato
  • Premium options often worth the investment
  • Ignore fonts with less than 5 weights

Play it safe:

  • Neutral fonts work for almost everything
  • You don't need a personality-filled font

Optimize for legibility:

  • Good font makes all the difference in readability
  • Poor font choice can make even good design feel unprofessional

Keep Your Line Length in Check

  • Long-form text: 60-75 characters per line. Below 60 the eye jumps back to the left edge too often to hold a paragraph together.
  • 45 characters is the floor, and only for narrow columns: sidebars, captions, table cells, a card's supporting line. Prose does not live there.
  • If text is too wide, decrease font size or limit width
  • Don't use percentage-based widths for text

Use text-wrap: balance for Headings

A quick way to improve how text behaves is to use text-wrap: balance. It distributes text evenly across each line, avoiding orphaned words at the end.

h1, h2, h3 {
  text-wrap: balance;
}

Limitations:

  • Only supported for short content (<6-10 lines depending on the browser)
  • Best used for headings or short paragraphs, not body text

Use text-wrap: pretty for Body Text

balance is for short, multi-line headings. For long-form body copy, use text-wrap: pretty instead. It is cheaper for the browser (it only looks at the last few lines rather than balancing the whole block) and its job is narrower: prevent a single orphaned word on the final line, and avoid awkward short last lines, without re-flowing the entire paragraph.

p,
li {
  text-wrap: pretty;
}

h1, h2, h3 {
  text-wrap: balance;
}

Rule of thumb: balance for headings and short blocks, pretty for paragraphs. Do not put balance on long body text, it gets ignored past the browser's line cap and costs more to compute.

Baseline, Not Center

  • When mixing different font sizes, align by baseline (not vertical center)
  • Forms: align label and input by baseline
  • Exception: Single line of text with icon - center align is okay

Line-height is Proportional

General rule:

  • Content within the 45-75 character range = normal line height (1.5-2)
  • Wide content = taller line height needed
  • Short lines = shorter line height works

By font size:

  • Large text (headings) = shorter line height (1.1-1.3)
  • Body text = medium line height (1.5)
  • Small text = taller line height (needed for legibility)
  • Links in blocks of text should be obvious (colored, underlined)
  • Links in navigation don't need special treatment
  • If it's obviously clickable from context, you don't need link styling

Set Underlines from the Font

The browser's default underline sits at a position the browser picked, and it runs straight through the descenders of g, j, p, q and y. Two properties fix both problems, and they belong on the root so every underline in the product inherits them.

:root {
  /* The font's own designer chose where this line goes. */
  text-underline-position: from-font;
  /* The line breaks around a descender instead of striking through it. */
  text-decoration-skip-ink: auto;
}

text-underline-position: from-font reads the underline position the typeface ships, so the line sits where that face was drawn to carry it. It says nothing about how thick the line is. Thickness is a separate property, and it takes its own from-font value:

:root {
  /* Optional: take the weight from the font too, not just the position. */
  text-decoration-thickness: from-font;
}

skip-ink: auto is the browser default in current engines, but state it anyway: a reset stylesheet that sets the text-decoration shorthand can clear it, and the failure is silent.

Store Copy in Natural Case

Write every string in its natural sentence case, then let text-transform do the presentation. A label that reads SAVE CHANGES in the source has thrown the real casing away, and nothing downstream can get it back.

// ✅ The string keeps its meaning; the CSS makes it loud.
<span className="uppercase tracking-wide">Save changes</span>

// ❌ The casing is now baked into the data.
<span>SAVE CHANGES</span>

Three things break when the case is baked in. A screen reader may spell out an all-caps string letter by letter. Search and filtering against the string stop matching what the user typed. And when the design drops the uppercase treatment, every string needs rewriting instead of one class being deleted. The same rule covers translated copy, where the target language may not use capitals the way English does. See "Use Letter-spacing Effectively" below for when uppercase is the right presentation at all.

Align with Readability in Mind

Center alignment:

  • Fine for headlines or short blocks
  • Don't center long content (hard to read)

Right alignment:

  • Good for numbers in tables
  • Generally avoid for long content

Justified text:

  • Creates awkward spacing
  • Generally avoid

Left alignment:

  • Safest choice for most content

Use Letter-spacing Effectively

Tighten headlines:

  • Large text often needs negative letter-spacing (tracking)
  • Makes headlines feel more cohesive

Open up all-caps:

  • All caps text needs increased letter-spacing
  • Otherwise letters feel too cramped
  • But reach for all-caps rarely. Do not set UI labels as tiny spaced capitals (text-xs uppercase tracking-widest): section labels, stat captions, table column headers, card headers, and badges all read faster in sentence case at the design system's own label size. Uppercase strips the ascender and descender shapes readers use to recognize words, so it is slowest exactly where the type is smallest, and repeated down a screen it is a generated-UI tell. Use it when the source design system genuinely does, not as a substitute for hierarchy that size, weight, and color should be carrying. See review-rules.md (Letter-spaced capital labels).

Font Weights

font-normal: 400-500
font-bold: 600-700

Two weights are enough for most views: the body weight, and one heavier weight for headings and a single lead figure. A third weight has to earn its place by marking a level the first two cannot.

Weight is also not a general-purpose highlighter. Bolding a phrase inside a paragraph, or bolding every card title, stat, and label on the same screen, spends the strongest signal typography has on words that were not competing for attention. When a quarter of a screen is bold, nothing on it reads as emphasized. Emphasize with position and size first, then color, and keep weight for the hierarchy itself. See review-rules.md (Mixed font families and weights).

Tabular Numbers

By default, many fonts use proportional numerals — 1 is narrower than 8, and a number changing from 19 to 20 can subtly shift the layout around it. Tabular numbers give every digit the same width.

Use tabular numbers for:

  • Numbers in columns (tables, dashboards, leaderboards, invoice totals)
  • Numbers that update in place (timers, countdowns, prices, live metrics, OTP inputs, download progress)

Don't use for:

  • Body text, paragraphs, or static content where alignment isn't doing any work — proportional numerals look more natural there
/* Numbers in columns */
td[data-type="number"] {
  font-variant-numeric: tabular-nums;
}

/* Numbers that update in place */
.timer,
.price,
.metric-value {
  font-variant-numeric: tabular-nums;
}
<!-- Tailwind -->
<span class="tabular-nums">01:59</span>

This gets more important as values get longer. 1,240, 98, and 12,004 are much easier to scan when each digit occupies the same width. Users won't name this detail, but they'll feel it.

Font Smoothing on macOS

On macOS and iOS, browsers default to subpixel-antialiased text, which renders glyphs slightly heavier than the font's true weight. On light backgrounds this is fine, but light text on a dark background looks bloated and fuzzy because the subpixel rendering thickens the strokes. Switching to grayscale antialiasing makes the text crisper and truer to the designed weight.

body {
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
}

When to apply:

  • Apply globally on body when your UI is predominantly light-on-dark (dark theme, hero sections, dark cards). This is the single biggest "feels designed" type tweak on a dark UI.
  • It is a macOS/iOS-only concern: -webkit-font-smoothing is a no-op on Windows/Android, so it is safe to set unconditionally.

Caveats:

  • Do not toggle it per-element on hover or animation, switching between subpixel and grayscale mid-transition causes a visible weight jump.
  • On light-on-light text the default subpixel rendering is often slightly more legible, so only force antialiased where you actually have the dark-background problem, or accept the small trade for consistency across the app.

Detecting the Browser Default Font Size

The browser default font size is usually 16px, but users can change it. Reading the computed style on html won't work because your stylesheet might have overridden it.

The trick is the CSS medium keyword. It always resolves to the browser's configured default, no matter what your CSS sets on :root or html.

const el = document.createElement("div");
el.style.fontSize = "medium";
document.body.appendChild(el);

const defaultSize = parseFloat(getComputedStyle(el).fontSize);
document.body.removeChild(el);

console.log(defaultSize); // 16, 20, 24, etc.

If the user has their browser set to "Very Large," this returns 24 instead of 16. Useful when rendering text on a canvas, working inside a third-party embed, or calculating relative sizes accurately.

Typography Details

Typography is a system of relationships. Tune measure, leading, scale, tracking, wrapping, and numeric behavior together.

Practical defaults:

  • keep continuous prose at 60–75 characters per line, with roughly 66ch as a useful starting point, and hold 45 as the floor for narrow columns only;
  • begin body leading around 1.4–1.6, adding space as lines grow wider;
  • reduce leading for large display type because its glyphs already create strong vertical presence;
  • use a restrained scale of roughly five sizes and inspect the steps below the body size carefully;
  • reduce tracking as type grows, but judge the actual face rather than relying on a formula;
  • use tabular figures for values that update or align in columns;
  • right-align comparable numbers while keeping their labels easy to scan;
  • prefer deliberate balanced or pretty wrapping for headings, not manual line breaks that fail responsively;
  • use correct punctuation and allow hanging punctuation when it improves the text edge;
  • consider a slightly lighter weight in dark mode, where bright text can appear heavier.
:root {
  --type-step--1: clamp(0.82rem, 0.8rem + 0.1cqi, 0.88rem);
  --type-step-0: clamp(1rem, 0.96rem + 0.2cqi, 1.12rem);
  --type-step-1: clamp(1.28rem, 1.15rem + 0.55cqi, 1.65rem);
  --type-step-2: clamp(1.72rem, 1.42rem + 1.2cqi, 2.5rem);
}

.prose {
  max-inline-size: 66ch;
  font-size: var(--type-step-0);
  line-height: 1.52;
  text-wrap: pretty;
}

.metric {
  font-variant-numeric: tabular-nums slashed-zero;
  text-align: end;
}

.display-title {
  font-size: var(--type-step-2);
  line-height: 1.04;
  letter-spacing: -0.025em;
  text-wrap: balance;
}

OpenType features such as fractions, superscripts, subscripts, small caps, case-sensitive forms, character variants, and stylistic sets are useful when they solve a real reading problem or carry intentional voice. Test browser and font support before depending on them.

For identifiers and filenames, truncation should preserve the part people distinguish. A middle truncation can retain both a recognizable prefix and the extension. Where supported, text-box trimming can align visible glyph edges more precisely than the font’s full line box.

Checklist

  • Line height: smaller for headings (1.2), larger for body (1.5-1.8)
  • Align by baseline when mixing sizes
  • Increase letter-spacing for all-caps
  • Decrease letter-spacing for large headings
  • Left-align most text
  • Long-form line length 60-75 characters, with 45 the floor for narrow columns
  • Underlines set with text-underline-position: from-font and text-decoration-skip-ink: auto
  • Copy stored in natural case, with text-transform doing the presentation
  • Use px/rem, never em, for font sizes
  • Sizes for distinct roles jump ~25% (16px to 20px, not 16px to 18px)
  • Use text-wrap: balance on headings and short paragraphs
  • Use text-wrap: pretty on body paragraphs and list items
  • Use tabular-nums for numbers in columns or that update in place
  • Set -webkit-font-smoothing: antialiased for light-on-dark text

Truncation and Overflow

Truncation hides content. Only reach for it when the layout genuinely can't grow, and always keep the full value reachable (tooltip, expand, or a details view). A clipped label with no way to see the rest is a bug, not a design.

Single line needs all three declarations together, missing one and it silently won't clip:

.truncate-line {
  overflow: hidden;
  white-space: nowrap;
  text-overflow: ellipsis;
}
<!-- Tailwind rolls all three into one class -->
<span class="truncate" title="acme-production-us-east-1">acme-production-us-east-1</span>

Multiple lines use line-clamp, which cuts off after N lines with an ellipsis:

.clamp-2 {
  display: -webkit-box;
  -webkit-box-orient: vertical;
  -webkit-line-clamp: 2;
  overflow: hidden;
}
<!-- Tailwind -->
<p class="line-clamp-2">{description}</p>

Long unbreakable tokens (URLs, IDs, hashes, email addresses) overflow their container because there's no space to break at. overflow-wrap: break-word lets them break before they escape:

.break-tokens {
  overflow-wrap: break-word; /* Tailwind: break-words */
}

Reach for break-word, not word-break: break-all: break-word only breaks a word when it can't fit, so normal prose still wraps at spaces, while break-all breaks mid-word everywhere and shreds readable text.

Keep the full value reachable whenever you truncate:

  • Native title attribute for a quick hover tooltip (keyboard users won't get it, so don't rely on it alone for critical values).
  • A real tooltip component, or an expand/collapse toggle, for anything a user needs to act on.
  • Make the truncated text selectable so a copy still yields the whole string.

16px Inputs on Mobile

Focusing an input whose text is smaller than 16px makes iOS Safari zoom the whole page in, then leave it zoomed. 16px is the web default, and Safari treats anything smaller as too small to type into comfortably. The fix is to render inputs at 16px on mobile and drop to your smaller UI size from the sm breakpoint up:

<!-- Good: 16px on mobile, 14px from sm up. No zoom, still compact on desktop. -->
<input class="text-base sm:text-sm" type="email" />
/* Same idea in plain CSS */
input,
select,
textarea {
  font-size: 16px;
}

@media (min-width: 640px) {
  input,
  select,
  textarea {
    font-size: 0.875rem;
  }
}

Apply this to every focusable field: input, select, textarea, and any contenteditable surface.

Never disable zoom to work around it. <meta name="viewport" content="maximum-scale=1"> or user-scalable=no stops the auto-zoom, but it also blocks the pinch-zoom users rely on to read, which fails WCAG 1.4.4 (Resize Text). Safari even ignores the cap for pinch on its own, so you break accessibility everywhere else and don't fully fix the thing you aimed at. The 16px input size solves the zoom with zero accessibility cost.

Variable Fonts and OpenType

Modern font files carry abilities beyond drawing letters. Reach them from CSS, and prefer the high-level property over the raw axis or feature tag wherever one exists.

Optical sizing tunes a glyph's detail and spacing to the size it renders at (finer at display sizes, sturdier at reading sizes). If the font ships an opsz axis, turn it on and let it track font-size automatically:

body {
  font-optical-sizing: auto;
}

Prefer properties over raw tags. A property like font-weight keeps working when a non-variable fallback font renders; the equivalent font-variation-settings silently does nothing on a fallback. Save font-variation-settings for custom axes with no property of their own, and font-feature-settings for features with no property:

/* Good: common axis via the property, survives fallback */
.heading {
  font-weight: 650;
  font-optical-sizing: auto;
}

/* Bad: weight via raw tag breaks the moment a fallback renders */
.heading {
  font-variation-settings: "wght" 650;
}

/* Good: custom axis has no property, so the raw tag is correct here */
.heading-grade {
  font-variation-settings: "GRAD" 80;
}

The same rule applies to OpenType features: use font-variant-* first, font-feature-settings only for tags with no property.

WantUse the propertyNot the tag
Equal-width digitsfont-variant-numeric: tabular-numsfont-feature-settings: "tnum"
0 distinct from Ofont-variant-numeric: slashed-zerofont-feature-settings: "zero"
Real small capsfont-variant-caps: small-capsfont-feature-settings: "smcp"

Stylistic sets and character variants (ss01ss20, cv01cv99) have no property because what each numbered slot does differs font to font, so check the font's docs. These are the one legitimate place for font-feature-settings:

/* Inter: ss01 switches to open digits; cv11 swaps in a single-story 'a' */
.brand {
  font-feature-settings: "ss01" 1, "cv11" 1;
}
<!-- Tailwind arbitrary value -->
<span class="[font-feature-settings:'ss01'_1]">1234567890</span>

slashed-zero earns its place in any UI that shows codes, IDs, or serial numbers, where telling 0 from O matters. tabular-nums (covered above) is the other one you'll reach for constantly.

Smart Punctuation

Keyboard characters are not always the right typographic characters. Use the real glyph in prose (curly quotes, en dash, a true ellipsis), and keep the straight ASCII characters in code, where they carry meaning.

Instead ofUseWhy
Straight quotes "..." '...'Curly quotes "..." '...'They curve around the text and read as real typography. Keep straight quotes in code samples.
Hyphen in a range 2010-2020En dash 2010–2020The en dash is the correct glyph for a span of values. Write ranges with "to" in running prose.
Three periods ...The single ellipsis character One glyph, correctly spaced, and it won't get split across a line break.
A plain space inside a value like 16 px&nbsp; (non-breaking space)Keeps the number and its unit on the same line so the value never breaks apart.
An uncontrolled break inside a long word&shy; (soft hyphen)Marks where a word may break, invisible unless the break is actually needed.
<!-- Curly quotes and a real ellipsis in copy -->
<p>&ldquo;Design is how it works&rdquo; &mdash; loading&hellip;</p>

<!-- Never let a value split across lines -->
<span>16&nbsp;px</span>

<!-- Range with the correct glyph -->
<span>9&nbsp;am&ndash;5&nbsp;pm</span>

If you set the lang attribute correctly (see below), the browser even picks the right quote glyphs for CSS-generated quotes and for the language's conventions, so declaring language does double duty.

Internationalisation and Bidi

Text is not always LTR English, and a few attributes keep it correct in other languages and writing directions.

Set lang. It tells the browser which quote glyphs, hyphenation dictionary, and pronunciation to use, and it drives correct line breaking. Set it on <html> for the page, and on any element whose content is in a different language:

<html lang="en">
  ...
  <blockquote lang="fr">Le mieux est l'ennemi du bien</blockquote>
</html>

Set dir at the document, or at the boundary where direction changes. Right-to-left scripts (Arabic, Hebrew) need dir="rtl" so the whole layout mirrors:

<html lang="ar" dir="rtl">

Never reverse digits. Numbers keep their digit order in every writing direction: a phone number, a price, or 541 reads identically LTR and RTL. The browser's Unicode bidi algorithm handles this, so don't fight it by manually reordering digits.

Use <bdi> for user-generated content whose direction you can't predict (usernames, place names, free-text values). It isolates the run so a right-to-left value can't flip the punctuation or numbers around it:

<!-- Without <bdi>, an RTL username can reorder the score and brackets -->
<span><bdi>{username}</bdi>: {score} points</span>

A short one- or two-line snippet follows the surrounding UI's direction, but a paragraph of three or more lines should align to its own script: an English paragraph stays start-aligned LTR even inside an RTL interface. text-align: start plus the right lang/dir on that element handles it.

Use this guidance in your coding agent

Install the Better Design MCP once. Your agent then loads this page with one call.

get-ui-principle({ topic: "typography" })
claude mcp add --scope user better-design --transport http https://better-design.com/api/mcp --header "Authorization: Bearer <YOUR_API_KEY>"
Browse related design systems