When "sp" is not enough for font scaling
Most Android devs are aware of using sp for text sizes and line heights. There are lint checks which will catch that for you. But sometimes, just using sp doesn’t make your app scale the text in a legible way.
For most developers, our workflow involves passing the Figma link to the agent, which looks at the specifications and copies it over. Just like the Android best practices for font scaling, Figma specifies the font size in sp and the component size in dp. Under default device settings and short labels, everything looks pixel perfect. However, as soon as you scale the system font settings, let’s say to 200%, your UI might start to break. The might here is dependent on if you hardcoded the height of the UI component and if the sp*fontScale outgrew that height.
The Example
Let’s look at the following button that has a rounded pill shape, a centered 24sp label, and a 48dp height (as probably specified in Figma):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Composable
fun PillButton(
text: String,
modifier: Modifier = Modifier,
) {
Surface(
shape = RoundedCornerShape(percent = 50),
color = Accent,
modifier = modifier.height(48.dp),
) {
Box(contentAlignment = Alignment.Center) {
Text(
text = text,
color = Color.White,
fontSize = 24.sp,
modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp),
)
}
}
}
At the default font scale (1.0x), the 24sp label and its padding fit comfortably inside the 48dp height:
Now scale the system font size to 200%:
In Compose, the system font scale is applied through Density.fontScale and multiplies every sp value, taking our label from 24sp to 48sp.
Meanwhile, dp values do not scale with fontScale, so the container stays at 48dp. A 48sp label (once you include its font metrics and vertical padding) is taller than 48dp even on a single line. Because Modifier.height(48.dp) sets both the minimum and maximum height constraints to 48.dp, the Surface refuses to grow. When Text is measured, the available height is locked at 48dp, and the bottom of the text gets clipped right off (slicing the descenders off y, g, and p in this specific example).
The Fix: Modifier.defaultMinSize
To be compatible with font scaling, we need to treat the Figma spec as a minimum height rather than a fixed constraint. This is where Modifier.defaultMinSize comes in:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Composable
fun PillButton(
text: String,
modifier: Modifier = Modifier,
) {
Surface(
shape = RoundedCornerShape(percent = 50),
color = Accent,
modifier = modifier.defaultMinSize(minHeight = 48.dp),
) {
Box(contentAlignment = Alignment.Center) {
Text(
text = text,
color = Color.White,
fontSize = 24.sp,
modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp),
)
}
}
}
At the default 1.0x font scale, the text plus its padding measures within 48dp, so the button looks identical to the Modifier.height(48.dp) version. Because both produce the exact same pixels at 1.0x scale, a hardcoded height is easy to miss during normal development and code review.
At 2.0x font scale, defaultMinSize allows the button to grow vertically to fit the scaled 48sp text:
This is also how Material 3’s own Button is built. It never sets a fixed height; instead, it puts defaultMinSize on the Row holding its content:
1
2
3
4
5
6
7
8
9
10
Row(
Modifier.defaultMinSize(
minWidth = ButtonDefaults.MinWidth,
minHeight = ButtonDefaults.MinHeight,
)
.padding(contentPadding),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
content = content,
)
ButtonDefaults.MinHeight acts as a floor, which is why a Material Button grows with scaled text instead of clipping.
How defaultMinSize Works Under the Hood
defaultMinSize is backed by a single layout node (UnspecifiedConstraintsNode), and its measure function is simple:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
override fun MeasureScope.measure(
measurable: Measurable,
constraints: Constraints,
): MeasureResult {
val wrappedConstraints =
Constraints(
if (minWidth.isSpecified && constraints.minWidth == 0) {
minWidth.roundToPx().fastCoerceIn(0, constraints.maxWidth)
} else {
constraints.minWidth
},
constraints.maxWidth,
if (minHeight.isSpecified && constraints.minHeight == 0) {
minHeight.roundToPx().fastCoerceIn(0, constraints.maxHeight)
} else {
constraints.minHeight
},
constraints.maxHeight,
)
val placeable = measurable.measure(wrappedConstraints)
return layout(placeable.width, placeable.height) {
placeable.placeRelative(0, 0)
}
}
Two important details from the measure function above:
maxWidthandmaxHeightare passed through untouched. The modifier only raises the minimum constraint and doesn’t impose a ceiling. This allows the button to grow whensp * fontScaleoutgrows the defined size (48dp in our example).- The
constraints.minHeight == 0check. This is the “default” part ofdefaultMinSize. The minimum height is only applied when the incoming minimum constraint is0. If a modifier earlier in the chain has already set a non-zerominHeight, that constraint wins anddefaultMinSizedoesn’t do anything. This makes it perfect for design systems where the caller might want to override thedefaultMinSizeor use percent-based sizing.
Which Sizing Modifier to Reach For
Nearly all Compose sizing modifiers have two important properties: whether the parent can override the sizing, and whether the child can grow past them:
defaultMinSize(minWidth, minHeight): Sets a minimum only when the incoming minimum is0. The parent can override it, and the child can grow past it.heightIn(min, max): Sets a height range even if the incoming minimum is non-zero (coerced within the parent’s constraints). The child can grow up tomax.height(x): Sets bothminHeightandmaxHeighttox. A tighter parent constraint will still win, but the child can never grow pastx.requiredHeight(x): Ignores incoming parent constraints and locks both minimum and maximum height tox.
A good rule of thumb for design systems is to save fixed modifiers (size, height) for intrinsic dimensions that don’t depend on text, like a 24dp icon or a 1dp divider. Anything involving text should use defaultMinSize or heightIn so the text can scale without being clipped.
Testing
Now that we’ve fixed the issue, how can we prevent it from happening again?
1. @PreviewFontScale
We can instruct agents (through Agents.md or skills) to always generate preview variants and look at them. @Preview accepts a fontScale parameter, and @PreviewFontScale expands a single preview into seven font scales (from 85% up to 200%):
1
2
3
4
5
6
7
@PreviewFontScale
@Composable
private fun PillButtonPreview() {
AppTheme {
PillButton(text = "Create your first group")
}
}
2. Compose UI Tests
If your previews feed into screenshot tests (like Roborazzi or Paparazzi), the 200% preview will catch a fixed-height regression visually. You can also test the behavior directly with createComposeRule() by driving fontScale from state and asserting that the button’s height increases:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
@get:Rule
val composeRule = createComposeRule()
private fun measureAtFontScales(
button: @Composable (Modifier) -> Unit,
): Map<Float, Dp> {
val fontScale = mutableFloatStateOf(1f)
composeRule.setContent {
val base = LocalDensity.current
CompositionLocalProvider(
LocalDensity provides Density(base.density, fontScale = fontScale.value)
) {
button(Modifier.testTag("pill"))
}
}
val heights = mutableMapOf<Float, Dp>()
listOf(1f, 2f).forEach { scale ->
composeRule.runOnIdle { fontScale.value = scale }
composeRule.waitForIdle()
heights[scale] = composeRule.onNodeWithTag("pill")
.fetchSemanticsNode()
.let { with(composeRule.density) { it.size.height.toDp() } }
}
return heights
}
@Test
fun minSizeButtonGrowsWithFontScale() {
val heights = measureAtFontScales { PillButton("Create your first group", it) }
assertThat(heights.getValue(1f)).isEqualTo(48.dp)
assertThat(heights.getValue(2f)).isGreaterThan(heights.getValue(1f))
}
fontScaleis updated viamutableFloatStateOfinside a singlesetContentblock becausecreateComposeRule()only allows callingsetContentonce per test.
TL;DR
Using sp for text sizes is only half the story for font scaling. When an agent (or developer) translates a fixed component height from Figma into Modifier.height(48.dp), the container is locked at 48dp even when sp * fontScale outgrows that height, clipping the label. Use Modifier.defaultMinSize(minHeight = 48.dp) for components that render text, so the spec height acts as a floor at default scale while allowing the component to grow when system font size increases.



