Why fillMaxWidth() sometimes does nothing
Compose sizing confuses people because Constraints is a min/max range rather than a value. Each sizing modifier transforms the range in a specific way, and reading them that way makes layout deterministic.
Day 21 of 100. Day 18 established that constraints travel down and sizes travel up. Today: what a constraint actually contains, and why that one detail resolves most sizing bugs.
The case this post is about
One card that refuses to fill its parent:
Row(modifier = Modifier.horizontalScroll(rememberScrollState())) {
Card(modifier = Modifier.fillMaxWidth()) { // does nothing
Text("why am I not wide")
}
}
fillMaxWidth is right there and the card is as wide as its text. No warning, no error, and the
same modifier works perfectly one screen over.
The reason is that fillMaxWidth means take my parent's maximum width, and a horizontally
scrolling parent has no maximum — the axis is unbounded, so there is no number to take. This post
follows that one card, because the instinct is to shout louder with an explicit width, and the
useful move is to ask why that axis scrolls.
The symptom: a card that refuses to fill its parent
A card that refuses to fill its parent:
Row(modifier = Modifier.horizontalScroll(rememberScrollState())) {
Card(modifier = Modifier.fillMaxWidth()) { // does nothing
Text("why am I not wide")
}
}
No crash, no warning. The card sizes to its content and fillMaxWidth() is silently
inert. Adding a second fillMaxWidth() doesn't help, and neither does
fillMaxSize().
Why forcing a width doesn't fix it
The obvious fix is to force a width:
Card(modifier = Modifier.width(400.dp)) { … }
It works on the device you're testing on. It's wrong on every other screen size, and it's wrong in landscape, and it will be wrong again when someone adds a tablet layout.
Worse, it hides the actual problem, which is that the parent offered infinite width. A hardcoded number papers over a structural decision made two composables up.

The mechanism: Constraints is four integers, not one
Constraints is four integers, not one:
Constraints(minWidth = 0, maxWidth = 1080, minHeight = 0, maxHeight = 1920)
A child must choose a size within that range. The parent decides the range; the child decides the value. Four shapes matter, and they have names worth using:
- Bounded —
maxWidthis a real number. Normal case. - Unbounded —
maxWidth = Infinity. Scrollable axes, andwrapContentSizein some configurations. - Exact —
min == max. The child has no choice at all. - Unspecified —
min = 0, max = Infinity. "Be whatever you want."
fillMaxWidth() means "set my width to maxWidth". When maxWidth is infinite,
there is no number to take, so the implementation falls back to wrapping content. That's
the whole bug: fillMaxWidth fills the bound, and a scrolling axis has no bound.
The fix is to bound the axis, not to shout louder:
Row(modifier = Modifier.horizontalScroll(state)) {
Card(modifier = Modifier.width(screenWidth)) { … } // an explicit bound
}
or — usually better — to ask whether that axis should scroll at all.
Every sizing modifier is a function from an incoming range to an outgoing one, and writing them that way makes the infinity case stop being a surprise:
data class Range(val min: Int, val max: Int) { // max == Int.MAX_VALUE means unbounded
val bounded get() = max != Int.MAX_VALUE
override fun toString() = "[$min..${if (bounded) "$max" else "inf"}]"
}
fun fillMax(incoming: Range, intrinsic: Int): Pair<Range, String> =
if (incoming.bounded) {
Range(incoming.max, incoming.max) to "took the bound"
} else {
// There is no number to fill TO. The implementation wraps content instead —
// silently, because "no bound" is not an error condition.
Range(intrinsic, intrinsic) to "no bound to fill; wrapped content"
}
fun width(incoming: Range, dp: Int): Pair<Range, String> {
val clamped = dp.coerceIn(incoming.min, incoming.max)
val note = if (clamped != dp) "requested $dp, parent allows $incoming" else "exact"
return Range(clamped, clamped) to note
}
The card in both parents, with the intrinsic width of its content at 180:
Column (bounded 1080):
fillMaxWidth [0..1080] -> [1080..1080] took the bound
Row(horizontalScroll) (unbounded):
fillMaxWidth [0..inf] -> [180..180] no bound to fill; wrapped content
width(1080) [0..inf] -> [1080..1080] exact
Column (bounded 320), card asking for 1080:
width(1080) [0..320] -> [320..320] requested 1080, parent allows [0..320]
Three lines, three different outcomes from the same modifier, and the middle one is the bug this post
is about. Note the last case too: width(1080) inside a 320dp parent does not produce a 1080dp card
and does not complain — coerceIn is the whole of it. The parent decides the range; the child decides
the value; a child asking for a value outside the range gets the nearest one in it.
Every sizing modifier as a range transformation
Once constraints are a range, each modifier is a function on it. This table is the whole sizing API:
| Modifier | Transformation |
|---|---|
size(100.dp) |
min = max = 100 (clamped to the incoming range) |
requiredSize(100.dp) |
min = max = 100, ignoring the incoming range |
width(100.dp) |
same, width axis only |
fillMaxWidth(f) |
min = max = maxWidth * f |
wrapContentWidth() |
min = 0, max unchanged — child may be smaller |
widthIn(min, max) |
clamps the incoming range |
defaultMinSize(w, h) |
raises min only if it is currently 0 |
weight(f) |
max = the child's share of the remainder |
Two rows there explain most surprises.
size is clamped; requiredSize is not. A Box(Modifier.size(100.dp)) containing
Box(Modifier.size(200.dp)) renders at 100dp, because the inner box's request is
clamped to the parent's exact constraint. requiredSize(200.dp) overflows instead — the
child wins and draws outside its parent. Neither is a bug; they are the two answers to
"who decides", and the name says which.
defaultMinSize only applies when nothing else did. It's how Material components
get their 48dp touch targets without overriding an explicit caller size. If you set a
size, it stays; if you don't, you get the accessible default.
Reading the range yourself
BoxWithConstraints exposes the incoming constraints to a composable, which is the
direct way to answer "what was I actually offered":
BoxWithConstraints {
Text("max width: $maxWidth, min: $minWidth, bounded: ${constraints.hasBoundedWidth}")
if (maxWidth < 600.dp) CompactLayout() else WideLayout()
}
Two caveats worth knowing before reaching for it. It's a SubcomposeLayout, so its
content is composed during the layout phase rather than composition — the extra pass
Day 18 mentioned. And it answers "how much room do I have", which is not always the same
question as "what kind of device is this". For the second, window size classes are the
right tool, and that's Day 29.
Intrinsics, revisited precisely
IntrinsicSize.Min and .Max ask a child "how small could you be while still rendering
correctly" and "how large would you like to be" without measuring it for real:
Row(Modifier.height(IntrinsicSize.Min)) {
Text("short")
Divider(Modifier.fillMaxHeight().width(1.dp)) // now matches the tallest sibling
Text("a much\nlonger label")
}
Without the intrinsic, fillMaxHeight() on the divider has an unbounded height
constraint to fill and collapses. With it, the row resolves its height first, so the
divider has a real number to fill.
The cost: an intrinsic query walks the subtree separately from the measure pass. Inside a
LazyColumn item that happens per visible item per frame. Acceptable for a divider in a
settings row; not something to put in a hot list.
The two-modifier trick for "fill, but no bigger"
A recurring requirement: an element should fill the available width, but never exceed a readable maximum. Written as two range transformations it's one line:
Modifier.fillMaxWidth().widthIn(max = 640.dp)
fillMaxWidth sets min = max = parentWidth; widthIn(max = 640) then clamps that to
640. On a phone nothing changes; on a tablet the content stops widening — the standard
fix for text lines that grow past comfortable reading width.
The centred version adds an alignment on the parent rather than a third sizing modifier:
Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.TopCenter) {
Content(Modifier.widthIn(max = 640.dp))
}
Reading both as range transformations is what makes the combination predictable rather than something to try and see.
How to prove it: print the constraints at the point of confusion
Print the constraints at the point of confusion. It converts a guess into a fact in about thirty seconds:
Layout(content = { YourComposable() }) { measurables, constraints ->
Log.d("Constraints", "$constraints boundedW=${constraints.hasBoundedWidth}")
val p = measurables.first().measure(constraints)
layout(p.width, p.height) { p.placeRelative(0, 0) }
}
Drop it around the misbehaving element. If hasBoundedWidth is false, fillMaxWidth
was never going to work and no amount of modifier reordering will change that — the fix
belongs in the ancestor that removed the bound.
Layout Inspector shows the resulting sizes, which tells you what happened. The log tells you why, and for constraint problems the why is the part you need.
What this generalizes to: negotiation instead of assignment
The generalisable idea is negotiation instead of assignment. A parent doesn't assign a size; it offers a range and the child picks. That's what makes the same component work in a fixed slot, a scrolling row and a wrap-content box without conditional code.
CSS arrived at the same place from the other direction: width: 100% is meaningless
inside a shrink-to-fit container for exactly the reason fillMaxWidth() is inert inside
a scrolling row — the percentage has nothing to be a percentage of. Recognising the
shape once means you diagnose it in any layout system.
Tomorrow, Day 22: writing your own modifiers with Modifier.Node, and when a custom
Layout is the honest answer instead.
Day 21 of a 100-day series on Jetpack Compose, working through the official documentation in order. Source: Constraints and modifier order.
One post a week, no digest
New writing when it lands. No roundups, no course pitch, no "10 tips".