Corner radius is a signal, and Material has five of them
Material's shape scale gives five corner sizes mapped to component categories. The system is simple; the interesting parts are clip ordering, asymmetric corners, and shapes that must respond to state.

Day 47 of 100. The third of MaterialTheme's three locals, and the smallest — which
makes it a good place to look at how the theme's pieces interact.
The symptom
A card with rounded corners and a square image:
Card(shape = RoundedCornerShape(12.dp)) {
Column {
AsyncImage(model = url, contentDescription = null) // square corners at the top
Text(title, Modifier.padding(16.dp))
}
}
The card's corners are rounded and the image's aren't, so the image pokes out of its container at the top two corners. It's subtle enough to survive review and obvious enough to look unfinished.
Why the obvious fix fails
The obvious fix is to round the image too:
AsyncImage(
model = url,
modifier = Modifier.clip(RoundedCornerShape(12.dp)), // all four corners
)
Now the image has rounded bottom corners in the middle of a card, which looks like a mistake rather than a design. The image needs its top two corners rounded to match the card, and its bottom two square because they meet the text.
The other failing instinct is to hardcode 12.dp in both places, which is Day 44's problem again — one number in two files, and a third when someone adds a variant.

The actual mechanism
Shapes holds five sizes, and Material components pick one by category:
MaterialTheme.shapes.extraSmall // 4.dp — chips, small buttons, snackbars
MaterialTheme.shapes.small // 8.dp — text fields, small cards
MaterialTheme.shapes.medium // 12.dp — cards, most surfaces
MaterialTheme.shapes.large // 16.dp — sheets, large cards, dialogs
MaterialTheme.shapes.extraLarge // 28.dp — FABs, prominent surfaces
So the card and the image reference the same source:
val cardShape = MaterialTheme.shapes.medium
Card(shape = cardShape) {
Column {
AsyncImage(
model = url,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.clip(cardShape.copy(
bottomStart = CornerSize(0.dp),
bottomEnd = CornerSize(0.dp),
)),
)
Text(title, Modifier.padding(16.dp))
}
}
RoundedCornerShape is a data class, so copy with per-corner overrides is the idiom.
Change the theme's medium and both follow.
Note bottomStart/bottomEnd rather than bottomLeft/bottomRight — the start/end
naming is layout-direction aware, so the shape mirrors correctly in Arabic and Hebrew. The
left/right variants exist and are almost always the wrong choice.
Clip order, revisited
Day 20's rule does real work here. clip only affects what's drawn inside it:
Modifier.background(Blue).clip(shape) // clip after paint — still a rectangle
Modifier.clip(shape).background(Blue) // clip outside — rounded
And the version that catches people with images plus borders:
Modifier
.clip(shape) // 1. establish the shape
.background(surface) // 2. painted inside it
.border(1.dp, outline, shape) // 3. border needs the shape AGAIN
border takes its own shape parameter because it draws around the content rather than
inside the clip. Passing the same shape to both is the pattern; omitting it from border
gives you a rectangular outline around rounded content.
Shapes that respond to state
Material 3's expressive components animate shape on interaction — a button that's a squircle at rest and rounder when pressed. The mechanism is an animated shape rather than two shapes:
val interactionSource = remember { MutableInteractionSource() }
val pressed by interactionSource.collectIsPressedAsState()
val corner by animateDpAsState(if (pressed) 8.dp else 20.dp, label = "corner")
Box(
Modifier
.clip(RoundedCornerShape(corner))
.background(MaterialTheme.colorScheme.primary)
.clickable(interactionSource, indication = null) { … }
)
Worth noting the cost: clip with a changing shape re-renders on every animation frame,
which is a draw-phase read — Day 10 says that's the cheap phase, so this is fine. The
same animation driven through a recomposing if would not be.
When not to use the scale
Two legitimate departures.
CircleShape for anything genuinely circular — avatars, icon buttons, badges. It's a
distinct meaning rather than a large radius, and RoundedCornerShape(50) (the percent
overload) is the equivalent when the element isn't square.
Zero corners for full-bleed content. An edge-to-edge hero image should not be rounded at the screen edge; rounding only looks right when there's a background behind it.
A related judgment: nested rounded surfaces need decreasing radii, not matching ones. A 12dp card containing an 12dp inner surface looks wrong at the corners, because the inner one's curve has less room. The convention is inner radius = outer radius minus the padding between them — 12dp card with 8dp padding gives a 4dp inner shape.
The departure that isn't legitimate is picking a radius by eye per component. If a surface needs a radius the scale doesn't have, the scale is wrong — change it once in the theme rather than at the call site.
How to prove it
Shape bugs are visual, so screenshot tests earn their place here more than in most areas:
@Test fun cardImageCornersMatch() {
captureRoboImage("card_with_image.png") { AppTheme { MediaCard(sample) } }
}
Roborazzi or Paparazzi on the JVM, no device needed. The image-corner bug at the top of this post is invisible in a unit test and obvious in a golden image. It's also the one category of bug where a reviewer looking at a diff genuinely cannot catch it.
For layout direction, one preview:
@Preview(locale = "ar")
@Composable fun MediaCardRtl() = AppTheme { MediaCard(sample) }
If the asymmetric corners are on the wrong side, you used bottomLeft instead of
bottomStart.
Shape and touch targets are unrelated
One trap worth naming because it looks like a shape problem. Rounding a button's corners does not shrink its touch target — the clip affects drawing, not hit testing:
Box(
Modifier
.size(56.dp)
.clip(CircleShape)
.background(primary)
.clickable { … } // the whole 56dp SQUARE is tappable
)
Taps in the corners, outside the visible circle, still register. Usually that's helpful — a slightly forgiving target. Occasionally it isn't: two circular buttons close together have overlapping square hit areas, and the gap between them belongs to whichever is later in the composition.
If the distinction matters, move clickable before clip so the interaction is bounded
by the same shape — Day 20's ordering rule, with a hit-testing consequence rather than a
visual one.
What this generalizes to
Shape is the smallest of the three theme locals and it makes the same point as the other two: the value should live once, named by role, and be referenced everywhere else. Card radius, image radius and border radius are one decision expressed three times, and the only question is whether the three stay in agreement.
That's the whole theming argument in miniature — and it's why MaterialTheme being just
three CompositionLocals is enough. Colour, type and shape are the three things a
design change usually touches, and having each addressable by name is what makes a
redesign a theme edit rather than a migration.
The corollary is that anything a design system needs beyond those three — spacing, elevation, motion durations, semantic colour — has to be added, because Material doesn't model it. That is tomorrow's subject, and the good news is that the mechanism is one you already know.
Tomorrow, Day 48: extending the theme with values Material doesn't model — spacing, elevation, semantic colours — without abandoning it.
Day 47 of a 100-day series on Jetpack Compose, working through the official documentation in order. Source: Material 3 shape.