Fifteen named text styles are fewer decisions than five
Material's type scale gives fifteen named roles instead of a font-size parameter. Naming the role rather than the size is what makes typography consistent across a team and correct at large font scales.
Day 46 of 100. The type scale is the part of Material people most often bypass, and the bypass has a specific cost that shows up in accessibility testing.
The case this post is about
One balance card, with its three text sizes chosen in the file that draws it:
Text("Balance", fontSize = 14.sp, color = Color.Gray)
Text("$1,240.00", fontSize = 28.sp, fontWeight = FontWeight.Bold)
Text("Updated 2 min ago", fontSize = 11.sp, color = Color.Gray)
Three sizes, all reasonable, none of them shared with the card on the next screen — which uses 13, 26 and 12 for the same three roles because somebody else wrote it.
Extracting a small/medium/large scale is the obvious fix and solves the wrong half: it makes the numbers consistent and still asks every author how big should this be, which is a question with no right answer.
This post follows that one card, because the scale that works asks a different question — what is this text's job — and that one does have a right answer.
The symptom: every screen's text sizes slightly different
A screen where every text size is slightly different from every other screen:
Text("Balance", fontSize = 14.sp, color = Color.Gray)
Text("$1,240.00", fontSize = 28.sp, fontWeight = FontWeight.Bold)
Text("Updated 2 min ago", fontSize = 11.sp, color = Color.Gray)
It looks fine. The next screen uses 13, 26 and 12, because a different person wrote it and picked what looked right on their device. Six months in, the app has nineteen text sizes and no two screens agree.
Then a user turns font scaling up to 200% and the balance overlaps the label, because
28.sp grew and the layout around it didn't.
Why a constants file doesn't hold the line
The obvious fix is a constants file:
object TextSizes {
val small = 12.sp
val medium = 16.sp
val large = 24.sp
}
Better than literals, and it answers the wrong question. TextSizes.medium tells you a
number; it doesn't tell you what kind of text this is, so two people still disagree about
whether a card title is medium or large. And size alone isn't a style — line height,
letter spacing and weight all travel with it, and a constants file that holds only sizes
leaves those to be re-picked each time.

The mechanism: five categories × three sizes, each complete
Material's scale has five categories × three sizes, and each entry carries a complete
TextStyle — font, size, weight, line height and letter spacing together:
Text("Balance", style = MaterialTheme.typography.labelMedium)
Text("$1,240.00", style = MaterialTheme.typography.headlineLarge)
Text("Updated 2 min ago", style = MaterialTheme.typography.bodySmall)
The categories describe purpose, not size:
- Display (
displayLarge/Medium/Small) — the largest text on screen. One per screen at most, often none. A hero number, a splash headline. - Headline — high-emphasis section starts. Short.
- Title — medium-emphasis. Card titles, dialog titles, app bar titles.
- Body — running text. Paragraphs, descriptions, list item content.
- Label — UI chrome. Buttons, chips, captions, form labels.
Choosing between titleLarge and headlineSmall is a question about the text's job
rather than about pixels, and two developers asking that question converge. Two developers
asking "how big should this be" do not.
The practical rule that removes most disagreement: body for content, label for controls, title for names of things. Display and headline are for the rare cases.
What that rule buys is checkable, and checking it is worth more than agreeing with it. A screen's styles can be audited against the roles they claim:
enum class Role { Content, Control, Name, Hero }
data class Usage(val text: String, val role: Role, val style: String)
val EXPECTED = mapOf(
Role.Content to setOf("bodyLarge", "bodyMedium", "bodySmall"),
Role.Control to setOf("labelLarge", "labelMedium", "labelSmall"),
Role.Name to setOf("titleLarge", "titleMedium", "titleSmall"),
Role.Hero to setOf("displayLarge", "displayMedium", "displaySmall",
"headlineLarge", "headlineMedium", "headlineSmall"),
)
fun audit(usages: List<Usage>): List<String> {
val problems = usages.mapNotNull { u ->
if (u.style in EXPECTED.getValue(u.role)) null
else "${u.role} \"${u.text}\" uses ${u.style}; expected one of ${EXPECTED.getValue(u.role)}"
}
// The second rule, which no per-element check can see: Display is at most one.
val heroes = usages.filter { it.style.startsWith("display") }
return if (heroes.size > 1) problems + "${heroes.size} display styles on one screen: " +
heroes.joinToString { "\"${it.text}\"" } else problems
}
The account screen as it was written, before the scale was applied to it:
audit(listOf(
Usage("Balance", Role.Control, "bodySmall"),
Usage("$1,240.00", Role.Hero, "displayLarge"),
Usage("Recent activity", Role.Name, "displayMedium"),
Usage("Coffee — 4.20", Role.Content, "bodyMedium"),
Usage("Transfer", Role.Control, "labelLarge"),
)).forEach(::println)
Control "Balance" uses bodySmall; expected one of [labelLarge, labelMedium, labelSmall]
Name "Recent activity" uses displayMedium; expected one of [titleLarge, titleMedium, titleSmall]
2 display styles on one screen: "$1,240.00", "Recent activity"
Every one of those three was invisible on the screen. bodySmall and labelSmall differ by letter
spacing and weight, not size; a section heading at displayMedium looks like a bold section heading;
and two display styles look like a screen with two important things on it. The scale's value is not
that it picks sizes — it is that a role and a style are two separate statements, so they can disagree
and the disagreement can be printed.
Line height is the part you'd have got wrong
bodyLarge is 16sp with a 24sp line height — a 1.5 ratio. Set fontSize = 16.sp alone
and you get the font's default line height, which is tighter, and multi-line paragraphs
become noticeably harder to read.
That ratio is also why overriding a style should be a copy rather than a replacement:
// Loses line height and letter spacing
Text("Total", fontSize = 20.sp, fontWeight = FontWeight.Bold)
// Keeps the metrics, changes what you meant to change
Text("Total", style = MaterialTheme.typography.titleMedium.copy(
fontWeight = FontWeight.Bold,
))
copy on a named style is the correct escape hatch, and it keeps the relationship to the
scale visible.
sp, and the reason it isn't dp
Text sizes are sp because sp scales with the user's font-size preference. That's an
accessibility setting people genuinely use, and the scale handles it — the numbers grow.
What the scale can't handle is a layout that assumed the text wouldn't:
Box(Modifier.height(48.dp)) { Text("Label", style = …) } // clips at 200%
Text("Balance", maxLines = 1) // truncates at 200%
Fixed-height containers around text are the top cause of accessibility failures at large
font scales. Use defaultMinSize(minHeight = 48.dp) — Day 21's "raise the minimum only"
— so the container can grow.
Using dp for text to avoid the problem is the wrong fix, and it's worth naming because
it does get shipped: it makes the text ignore an accessibility setting entirely.
Android 14+ also changed how large scales behave — beyond 200% the scaling becomes non-linear, so already-large text grows proportionally less than small text. That keeps headlines from becoming absurd while captions still get the boost they need, and it means testing at 2× is testing the realistic worst case rather than an artificial one.
Custom fonts, and the variable-font case
Setting your own typeface is a Typography you provide once:
val Brand = FontFamily(
Font(R.font.brand_regular, FontWeight.Normal),
Font(R.font.brand_medium, FontWeight.Medium),
Font(R.font.brand_bold, FontWeight.Bold),
)
val AppTypography = Typography(
bodyLarge = Typography().bodyLarge.copy(fontFamily = Brand),
titleLarge = Typography().titleLarge.copy(fontFamily = Brand),
// …
)
Starting from Typography() and copying keeps every metric Material chose and changes
only the family, which is nearly always what you want.
For variable fonts, FontVariation gives you weights without shipping a file per weight:
Font(
R.font.brand_variable,
variationSettings = FontVariation.Settings(FontVariation.weight(500)),
)
One file, any weight — a real APK-size saving on a brand with six weights.
How to prove it: the font-scale test is one annotation
The font-scale test is the one that finds shipped bugs, and it's one annotation:
@Preview(fontScale = 1f) @Preview(fontScale = 1.5f) @Preview(fontScale = 2f)
@Composable fun BalanceCardScales() = AppTheme { BalanceCard(sample) }
Anything that clips, truncates or overlaps at 2× is a fixed dimension around scalable text. This preview costs nothing and covers an audience that's larger than most teams assume.
The consistency audit is a grep, like Day 44's:
grep -rn "fontSize = " --include="*.kt" app/src/main | grep -v "ui/theme/"
Every hit outside the theme is a size someone picked by eye. Most convert to a named style directly.
Emphasis without a second style
A recurring need: one word bold inside a sentence, or a label and value on one line with
different weights. Two styles side by side is the wrong reach — buildAnnotatedString
keeps it one Text and one baseline:
Text(
buildAnnotatedString {
append("Balance ")
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { append("$1,240.00") }
},
style = MaterialTheme.typography.bodyLarge,
)
Two Text composables in a Row would let the two halves wrap independently and sit on
different baselines at large font scales. One annotated string wraps as a unit, which is
what a sentence should do.
SpanStyle covers colour, weight, size and decoration; ParagraphStyle covers alignment
and line height for a block. Both stay inside the scale's metrics rather than replacing
them.
What this generalizes to: name the role instead of the value
The idea is naming the role instead of the value, which is Day 44's argument applied
to type. headlineSmall survives a redesign that changes every number in the scale;
24.sp does not, and there's no way to tell which 24.sp meant "headline" and which
meant "a slightly big body".
Fifteen names sounds like more to learn than three numbers. It's fewer decisions, because the choice is a question with a defensible answer — what job does this text do — rather than an aesthetic judgment made independently on every screen.
It's the same reason a well-named function is easier to call than a well-documented one with five boolean parameters. Constraint plus a good vocabulary removes work; constraint alone just removes options.
Tomorrow, Day 47: the shape system, and the corner radii that quietly signal what a surface is.
Day 46 of a 100-day series on Jetpack Compose, working through the official documentation in order. Source: Material 3 typography.
One post a week, no digest
New writing when it lands. No roundups, no course pitch, no "10 tips".