Go back

Clipping and Masking in Jetpack Compose

Android

Use Jetpack Compose clipping, custom shapes, blend modes, and offscreen compositing to build polished avatars and fading edges.

Modern apps are judged as much by their polish as by their core features. Smooth micro-interactions, subtle visual cues, and expressive UI elements can make an experience feel refined. In Jetpack Compose, two key techniques to achieve this polish are clipping and masking.

These concepts are rooted in graphics rendering, but Compose makes them approachable with modifiers, shapes, and blend modes. Let’s break down the fundamentals and see how to build practical effects like stacked avatars and fading edges.

What is Clipping?

Clipping means cutting away parts of your content outside a defined shape. Think of it as using a cookie cutter: everything inside the cutter remains, and everything outside is discarded.

In Compose, you do this with the Modifier.clip function:

Image(
painter = painterResource(R.drawable.avatar),
contentDescription = null,
modifier = Modifier
.size(72.dp)
.clip(CircleShape)
)

Here, the image is clipped to a circle, regardless of the bitmap’s actual bounds.

An image clipped to a circle

Custom Shapes

If the built-in shapes (CircleShape, RoundedCornerShape, and so on) are not enough, you can define a custom Shape and draw your own path. For example:

class SquishedOvalShape : Shape {
override fun createOutline(
size: Size,
layoutDirection: LayoutDirection,
density: Density
): Outline {
return Outline.Generic(
Path().apply {
addOval(Rect(0f, size.height / 4f, size.width, size.height))
}
)
}
}

Apply it like any other shape:

Modifier.clip(SquishedOvalShape())

A custom clipped shape

Beyond the Basics: Stacked Avatars

A common UI pattern is a row of overlapping circular avatars. At first glance, you can just clip each avatar to a circle and offset them horizontally. But notice a subtle detail: the avatars should not just overlap, they should carve into one another, letting the background peek through the edges.

Step 1: Draw Each Avatar

Start with a composable clipped to a circle:

@Composable
fun Avatar(image: Painter, modifier: Modifier = Modifier) {
Image(
painter = image,
contentDescription = null,
modifier = modifier
.size(48.dp)
.clip(CircleShape)
)
}

Step 2: Add a Clear Border

Use drawWithContent to first draw the avatar, then draw a border using BlendMode.Clear:

modifier.drawWithContent {
drawContent()
drawCircle(
color = Color.Black,
style = Stroke(width = 4f),
radius = size.minDimension / 2,
blendMode = BlendMode.Clear
)
}

The clear blend mode removes pixels in that region instead of painting over them.

A clear border around an avatar

Step 3: Isolate Layers with Offscreen Compositing

Without isolation, the clear operation would also punch holes into the background. To prevent that, wrap each avatar in a graphicsLayer:

Modifier.graphicsLayer {
compositingStrategy = CompositingStrategy.Offscreen
}

Now each avatar clears only within its own layer. When stacked with offsets, you get the polished overlap effect: background gradient visible in the gaps, but avatars intact.

Stacked avatars with isolated layers

Masking for Fade Effects

Masking is related to clipping, but it allows partial transparency. Instead of a hard edge, you fade content out smoothly. This is useful for scroll indicators, showing that a list continues beyond the visible area.

Step 1: Define a Gradient Mask

Create a black-to-transparent gradient:

val mask = Brush.verticalGradient(
colors = listOf(Color.Black, Color.Transparent)
)

Step 2: Apply with Blend Mode

Overlay it on the content with BlendMode.DstIn:

Box(
modifier = Modifier
.fillMaxWidth()
.height(24.dp)
.graphicsLayer {
compositingStrategy = CompositingStrategy.Offscreen
}
.drawWithContent {
drawContent()
drawRect(mask, blendMode = BlendMode.DstIn)
}
)

DstIn keeps only the destination pixels, your content, where the source, the mask, is drawn, resulting in a fade.

Step 3: Make It Reusable

Wrap this into a composable that accepts any content via a slot API:

@Composable
fun FadingEdgeBox(content: @Composable () -> Unit) {
Box {
content()
Box(
modifier = Modifier
.matchParentSize()
.graphicsLayer {
compositingStrategy = CompositingStrategy.Offscreen
}
.drawWithContent {
drawRect(
brush = Brush.verticalGradient(
colors = listOf(Color.Black, Color.Transparent)
),
blendMode = BlendMode.DstIn
)
}
)
}
}

Now you can pass a LazyColumn or any scrollable content, and it will automatically fade at the edge.

A fading edge on scrollable content

Why This Matters

These techniques are not just visual gimmicks. They solve real interaction problems:

  • Clipping ensures UI elements, such as avatars or profile pictures, remain visually consistent regardless of source image dimensions.
  • Stacked avatars with blend modes communicate group membership cleanly while maintaining a refined, layered aesthetic.
  • Masking with fades guides users subtly, showing affordances for scrolling without intrusive indicators.

Compose’s declarative model, combined with Modifier.clip, drawWithContent, blend modes, and compositing strategies, makes these effects straightforward to implement.

Takeaway

Clipping and masking are powerful tools in Compose that elevate an interface from functional to delightful. Start with the basics, clip with simple shapes, then experiment with custom paths, blend modes, and offscreen compositing. You will unlock effects that both enhance usability and polish your design.