A shadow needs a shape, and Compose won't guess one for you

Drawing custom shapes means Path plus DrawScope. Shadows are separate, and Modifier.shadow takes its own shape parameter — omitting it is why rounded cards get square shadows.

6 min read
androidcomposekotlingraphics

Day 59 — A shadow needs a shape, and Compose won't guess one for you

Day 59 of 100. Two related topics: drawing a shape that isn't a rectangle, and casting a shadow from it.

The symptom

A rounded card with a square shadow:

Box(
    Modifier
        .shadow(elevation = 8.dp)                    // no shape
        .clip(RoundedCornerShape(16.dp))
        .background(MaterialTheme.colorScheme.surface)
)

The card is rounded. The shadow underneath it has hard corners, poking out at each one. It reads as a rendering glitch, and it's a missing parameter.

Why the obvious fix fails

The obvious fix is to reorder, since Day 20 established that order matters:

Modifier.clip(RoundedCornerShape(16.dp)).shadow(8.dp)     // shadow now clipped away

Worse — the clip is now outside the shadow, so the shadow is drawn inside the clipped region and mostly disappears. A shadow is drawn outside the element's bounds by definition, so anything that clips before it removes it.

Order isn't the problem here. The shadow simply doesn't know what shape to be.

Modifier.shadow takes its own shape; clipping before it removes the shadow entirely

The actual mechanism

Modifier.shadow has a shape parameter, defaulting to RectangleShape:

val shape = RoundedCornerShape(16.dp)

Box(
    Modifier
        .shadow(elevation = 8.dp, shape = shape)     // shadow knows the outline
        .clip(shape)
        .background(MaterialTheme.colorScheme.surface)
)

The same shape passed twice — once so the shadow has an outline to cast, once so the content is clipped to it. That repetition is why a val for the shape is the idiom rather than two inline constructions that can drift.

Modifier.shadow also clips by default (clip = true for the content behind it), so in many cases the explicit clip is redundant. Keeping it costs nothing and makes the intent readable.

The reason it's not automatic: a modifier chain has no way to know that a clip further down was meant to describe the silhouette rather than just trim the content. Day 20's model — each element wraps the rest — means shadow genuinely cannot see what comes after it.

Material 3 does this for you

Worth saying before writing any of the above: Surface and Card take a shape and apply it to the shadow, the clip and the background together.

Surface(
    shape = MaterialTheme.shapes.medium,
    shadowElevation = 4.dp,
    tonalElevation = 2.dp,
) { content() }

Hand-rolling the three modifiers is the right move only when you need something Surface doesn't offer. Most square-shadow bugs are in code that reimplemented a card.

Day 47's note applies here too: in Material 3, tonalElevation tints the surface toward surfaceTint while shadowElevation casts the shadow. They're separate parameters because they express different things, and using tonal elevation alone is the M3-idiomatic way to show hierarchy on an opaque surface.

Drawing an arbitrary shape

For anything the built-in shapes don't cover, Path inside a draw scope:

Canvas(Modifier.size(100.dp)) {
    val path = Path().apply {
        moveTo(size.width / 2f, 0f)
        lineTo(size.width, size.height)
        lineTo(0f, size.height)
        close()
    }
    drawPath(path, color = Purple)
}

Coordinates come from size, not constants — Day 58's rule, unchanged. A triangle drawn with hardcoded pixel coordinates is the same bug as a gradient with a hardcoded end point.

Path supports the operations you'd expect — quadraticBezierTo, cubicTo, arcTo, addOval, addRoundRect — plus boolean combination:

val ticket = Path().apply {
    addRoundRect(RoundRect(0f, 0f, size.width, size.height, CornerRadius(16f)))
    op(this, notchPath, PathOperation.Difference)      // punch a notch out
}

PathOperation.Difference, Intersect, Union and Xor are how a ticket stub, a speech bubble with a tail, or a progress arc with a gap get drawn without approximating.

Turning a Path into a reusable Shape

A Path drawn in a Canvas is a one-off. Implementing Shape makes it available to clip, background, border and shadow — everything the built-ins work with:

class TicketShape(private val notchRadius: Dp) : Shape {
    override fun createOutline(
        size: Size,
        layoutDirection: LayoutDirection,
        density: Density,
    ): Outline {
        val r = with(density) { notchRadius.toPx() }
        return Outline.Generic(
            Path().apply {
                addRoundRect(RoundRect(0f, 0f, size.width, size.height, CornerRadius(24f)))
                addOval(Rect(-r, size.height / 2 - r, r, size.height / 2 + r))
                // …
            }
        )
    }
}

Box(Modifier.shadow(4.dp, TicketShape(12.dp)).background(surface))

Two things make this work properly. layoutDirection is passed in, so an asymmetric shape can mirror for RTL — the same concern as Day 47's bottomStart. And density is passed in, so the shape can convert dp to pixels itself rather than capturing a density that might be stale.

Outline.Rectangle and Outline.Rounded are cheaper than Outline.Generic when they suffice, because the renderer can take a fast path for them. Returning a generic outline for what is actually a rounded rectangle is a small, avoidable cost.

Shadows have limits

Three worth knowing before designing around them.

A shadow needs an opaque shape. A shadow under a translucent surface shows through it, which looks wrong. Surface handles this; a hand-rolled version with a semi-transparent background will not.

Shadow colour is limited. Modifier.shadow accepts ambientColor and spotColor on Android 9+, but coloured shadows are a platform feature rather than an arbitrary drawing — you can't blur an arbitrary brush into a shadow.

Elevation is not free. Each shadow is a render-node property, and a list where every row casts one costs more than a list using dividers or surfaceContainer roles for hierarchy. This is part of why Material 3 leans on tonal elevation.

How to prove it

The square-shadow bug is a screenshot test, and it's genuinely hard to see in a diff:

@Test fun cardShadowMatchesShape() {
    captureRoboImage("card_shadow.png") { AppTheme { PromoCard(sample) } }
}

For custom shapes, RTL is the check people skip:

@Preview(locale = "ar")
@Composable fun TicketRtl() = AppTheme { TicketCard(sample) }

If the notch is on the wrong side, createOutline is ignoring its layoutDirection parameter.

What this generalizes to

The idea is that a silhouette is separate information from the content. Clipping, casting a shadow and drawing a border all need to know the outline, and none of them can infer it from the others — so the outline becomes a value you define once and pass to each.

That's what Shape is: not a drawing, but a description of an outline that several subsystems can each use in their own way. Once it exists as a value, "the shadow doesn't match the card" stops being possible to express.

One more shadow that isn't Modifier.shadow

Worth knowing because it solves a case the elevation API can't: a soft glow, or a shadow in an arbitrary colour, is drawn rather than elevated.

Modifier.drawBehind {
    drawRoundRect(
        brush = Brush.radialGradient(
            listOf(glowColor.copy(alpha = 0.4f), Color.Transparent),
        ),
        cornerRadius = CornerRadius(24f),
    )
}

That's Day 58's brush doing a job elevation can't, and it composes with the real shadow rather than replacing it — a selected card can have both a system shadow for depth and a coloured glow for state.

The trade is that a drawn glow doesn't participate in the platform's lighting model, so it won't match the direction of real shadows elsewhere on screen. For a focus or selection indicator that's fine; for depth it isn't.

Tomorrow, Day 60 closes the graphics pillar with bitmap optimisation — memory, formats and the decode decisions that keep a photo app from being killed.


Day 59 of a 100-day series on Jetpack Compose, working through the official documentation in order. Source: Shapes in Compose.