Skip to content

Clipping and Masking in Jetpack Compose

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.

Custom Shapes

If the built-in shapes (CircleShape, RoundedCornerShape, etc.) aren’t 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())

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 shouldn’t 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.

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.

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.

Why This Matters

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

  • Clipping ensures UI elements (like avatars or profile pics) 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’ll unlock effects that both enhance usability and polish your design.

Photo by Kelly Sikkema on Unsplash