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