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 symptom
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 the obvious fix fails
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 actual mechanism
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.
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 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
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.