contentScale and Modifier.size are answering different questions
Image sizing depends on the modifier constraints and contentScale together. Understanding which one crops, which one fits, and where the aspect ratio comes from resolves most image layout bugs in one pass.

Day 55 of 100, opening the graphics pillar. Images are the most common source of "it looks wrong and I've tried every parameter" in a Compose codebase.
The symptom
An avatar that's an oval:
Image(
painter = painterResource(R.drawable.avatar),
contentDescription = null,
modifier = Modifier.size(48.dp).clip(CircleShape),
)
The source image is 400×300. The composable is 48×48. The image stretches to fill both dimensions, so a rectangular photo becomes a squashed square, then gets clipped to a circle — an oval face in a round frame.
The neighbouring thumbnail has the opposite problem: it keeps its proportions and leaves grey bars down the sides.
Why the obvious fix fails
The obvious fix is to try contentScale values until one looks right:
Image(…, contentScale = ContentScale.Fit) // now it letterboxes
Image(…, contentScale = ContentScale.Crop) // now it fills — but is it right?
Cycling through the enum does eventually produce something acceptable, and it doesn't tell you why, so the next image with a different source aspect ratio starts the cycle again.
The two parameters answer different questions, and knowing which is which makes the choice deterministic.

The actual mechanism
Two independent decisions:
Modifier decides the box. size, fillMaxWidth, aspectRatio — the constraints
from Day 21. This is the area the image occupies in the layout.
contentScale decides how the image fills that box. Given a source of one aspect
ratio and a box of another, something has to give: crop the image, letterbox the box, or
distort.
The default is ContentScale.Fit, which preserves aspect ratio and letterboxes. The
squashed avatar happens with ContentScale.FillBounds, or when the box's aspect ratio
happens to match nothing.
The values, by what they sacrifice:
| Value | Preserves ratio | Fills the box | Crops |
|---|---|---|---|
Fit |
✓ | ✗ (letterboxes) | ✗ |
Crop |
✓ | ✓ | ✓ |
FillBounds |
✗ (distorts) | ✓ | ✗ |
FillWidth / FillHeight |
✓ | one axis | maybe |
Inside |
✓ | only if smaller | ✗ |
None |
✓ | ✗ | maybe |
For an avatar in a fixed square, the answer is always Crop:
Image(
painter = painterResource(R.drawable.avatar),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.size(48.dp).clip(CircleShape),
)
Crop centres the image, scales it so the smaller dimension fills the box, and clips
the overflow. Faces survive; proportions survive.
The rule that resolves most cases: fixed box, unknown source ratio → Crop.
Known ratio you want to honour → aspectRatio on the modifier and Fit.
Where the aspect ratio should come from
For a feed of photos, letting each image dictate its own height produces the ragged layout Day 24 mentioned. Better to derive the box from data you have:
AsyncImage(
model = photo.url,
contentDescription = photo.caption,
contentScale = ContentScale.Crop,
modifier = Modifier
.fillMaxWidth()
.aspectRatio(photo.width.toFloat() / photo.height),
)
aspectRatio derives the height from the assigned width, so the box is known before
the image loads — no reflow when it arrives, and no scroll jump. That requires the
dimensions in your API response, which is worth asking for.
Without them, a fixed ratio (aspectRatio(16f / 9f)) plus Crop is the stable choice.
Letting the image decide is what causes the jitter.
Async loading
Compose has no built-in network image loader; Coil is the common choice and is Compose-native:
AsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data(photo.url)
.crossfade(true)
.build(),
contentDescription = photo.caption,
contentScale = ContentScale.Crop,
placeholder = painterResource(R.drawable.placeholder),
error = painterResource(R.drawable.broken),
modifier = Modifier.fillMaxWidth().aspectRatio(16f / 9f),
)
placeholder and error are the parameters most often left out, and they're the
difference between a loading feed that looks considered and one that looks broken. Day 41's
argument applies: the placeholder should occupy the same box as the final image, which
aspectRatio guarantees.
SubcomposeAsyncImage gives you full composable slots for the loading and error states
when a static painter isn't enough — at the cost of a subcomposition per image, so it's
not the default choice inside a long list.
contentDescription is not optional
The parameter has no default, which is deliberate — you have to decide:
Image(…, contentDescription = "Profile photo of ${user.name}") // meaningful
Image(…, contentDescription = null) // decorative
null is a real answer and means "this conveys nothing; skip it". A background texture, a
divider ornament or an icon next to a text label that already says the same thing should
all be null, because announcing them is noise.
What's wrong is a description that restates the obvious ("image", "photo") or duplicates adjacent text. The test: if you couldn't see the screen, would this sentence help? And if an image is the only content of a clickable row, its description is what the whole row announces — so it has to carry the action, not just the picture.
How to prove it
The sizing question is a preview with deliberately awkward sources:
@Preview
@Composable fun AvatarRatios() = Row {
Avatar(R.drawable.wide_400x300)
Avatar(R.drawable.tall_300x400)
Avatar(R.drawable.square_400x400)
}
All three should render as identical circles with undistorted faces. Any that's an oval
has the wrong contentScale; any that letterboxes has Fit where it needs Crop.
For the loading states, throttle the emulator's network and scroll a feed. Anything that shifts as images arrive has a box the image is deciding, not the layout.
Ask for the size you're going to draw
The single biggest image performance win has nothing to do with contentScale: don't
decode a 4000×3000 photo to fill a 400×300 thumbnail.
Coil resolves the target size from the composable's constraints automatically, which is most of why using it beats a hand-rolled loader. Where it can't — an unbounded axis — tell it:
AsyncImage(
model = ImageRequest.Builder(context)
.data(photo.url)
.size(400, 300) // decode at this size
.build(),
…
)
Better still, ask the server for the right size. A CDN that takes
?w=400 returns fewer bytes over the network as well as fewer pixels in memory, and the
saving compounds across a feed. Downloading a 3MB original to display it at thumbnail
size is a cost paid three times — bandwidth, decode time and heap — and none of it is
visible on a fast connection with a warm cache, which is exactly the condition you develop
under.
What this generalizes to
The reusable idea is separating the container decision from the content decision. The layout owns how much room there is; the content owns how it uses that room; and when the two disagree about aspect ratio, something must be sacrificed — and which thing is a choice you should make rather than discover.
CSS split the same problem into width/height and object-fit, with the same values
under different names. Recognising it as two questions rather than one parameter to fiddle
with is what makes it stop being trial and error.
Tomorrow, Day 56: ImageBitmap versus ImageVector — two representations, and picking the
wrong one costs memory or fidelity.
Day 55 of a 100-day series on Jetpack Compose, working through the official documentation in order. Source: Images in Compose.