
Jetpack Compose makes it simple to apply rounded or cut corners on components like Card via classes like RoundedCornerShape or CutCornerShape.
Those classes create shapes where all the corners for that particular shape either round or cut , but you can’t mix them. They allow ZeroCornerSize (0.dp) as corner size allowing you to actually mix and match them with sharp corners. When all corners are sharp can as well simple use RectangleShape.
So out of the box looks like is not possible to have a shape with cut and rounded corners. You know what else isn’t possible? Concave (inward) corners.
CornersShape
My goal was to create a shape that allows me to mix and match concave, convex, sharp and cut corners. I call it CornersShape.
The public API looks like this:
import androidx.compose.foundation.shape.CornerSize
import androidx.compose.foundation.shape.ZeroCornerSize
import androidx.compose.ui.unit.Dp
public sealed interface Corner {
public val cornerSize: CornerSize
public data class Concave(override val cornerSize: CornerSize) : Corner
public data class Rounded(override val cornerSize: CornerSize) : Corner
public data class Cut(override val cornerSize: CornerSize) : Corner
public data object Sharp : Corner {
override val cornerSize: CornerSize = ZeroCornerSize
}
public companion object {
public fun rounded(size: Dp): Corner = Rounded(CornerSize(size))
public fun cut(size: Dp): Corner = Cut(CornerSize(size))
public fun concave(size: Dp): Corner = Concave(CornerSize(size))
}
}
@Composable
public fun cornerShape(
bottomEnd: Corner = Rounded(MaterialTheme.shapes.large.bottomEnd),
bottomStart: Corner = Rounded(MaterialTheme.shapes.large.bottomStart),
topEnd: Corner = Rounded(MaterialTheme.shapes.large.topEnd),
topStart: Corner = Rounded(MaterialTheme.shapes.large.topStart),
): Shape
Note: MaterialTheme.shapes.* is typed as Shape. Accessing .topStart/etc. works when the theme shape is a CornerBasedShape. If your theme swaps it to a non-corner shape, you’ll want a safe cast (or just use fixed Dp defaults).
This is sample usage:
Card(
shape = cornerShape(
topStart = Corner.Sharp, // sharp 90°
topEnd = Corner.rounded(16.dp), // rounded
bottomEnd = Corner.concave(16.dp), // inward/concave
bottomStart = Corner.cut(32.dp), // diagonal cut
),
) {
// content
}

Under the hood: the mapper
@Composable
public fun cornerShape(
bottomEnd: Corner = Rounded(MaterialTheme.shapes.large.bottomEnd),
bottomStart: Corner = Rounded(MaterialTheme.shapes.large.bottomStart),
topEnd: Corner = Rounded(MaterialTheme.shapes.large.topEnd),
topStart: Corner = Rounded(MaterialTheme.shapes.large.topStart),
): Shape {
val corners = listOf(bottomEnd, bottomStart, topEnd, topStart)
return when {
corners.all { corner -> corner is Sharp } -> RectangleShape
corners.all { corner -> corner is Rounded || corner is Sharp } ->
RoundedCornerShape(
bottomEnd = bottomEnd.cornerSize,
bottomStart = bottomStart.cornerSize,
topEnd = topEnd.cornerSize,
topStart = topStart.cornerSize,
)
corners.all { corner -> corner is Cut || corner is Sharp } ->
CutCornerShape(
bottomEnd = bottomEnd.cornerSize,
bottomStart = bottomStart.cornerSize,
topEnd = topEnd.cornerSize,
topStart = topStart.cornerSize,
)
else -> CornerShape(
bottomEnd = bottomEnd,
bottomStart = bottomStart,
topEnd = topEnd,
topStart = topStart,
)
}
}
cornerShape() is the entry point. Here we use RectangleShape, RoundedCornerShape or CutCornerShape (all from compose libraries) when possible. If not possible it resolves to the custom CornerShape.
The custom engine — CornerShape
- Builds a rounded base (using RoundRect) for any rounded corners.
- For cut corners, subtracts a triangle anchored at the corner.
- For concave corners, subtracts an oval centered at the corner.
- Returns an Outline.Rounded when no subtraction is needed; otherwise Outline.Generic with a boolean difference.
/**
* A shape with per-corner customization: [Convex] (rounded), [Concave] (inward cut),
* [Cut] (diagonal cut), or [Sharp] (90-degree). Optimizes for simple cases (rectangle or rounded)
* and uses path subtraction for complex concave or cut corners.
*
* @param topStart The corner style for the top-start corner.
* @param topEnd The corner style for the top-end corner.
* @param bottomEnd The corner style for the bottom-end corner.
* @param bottomStart The corner style for the bottom-start corner.
*/
@Immutable
private class CornerShape(
private val topStart: Corner,
private val topEnd: Corner,
private val bottomEnd: Corner,
private val bottomStart: Corner,
) : Shape {
init {
listOf(topStart, topEnd, bottomEnd, bottomStart).forEach { corner ->
require(corner.cornerSize.toPx(Size(width = 100f, height = 100f), Density(1f)) >= 0f) {
"Corner size must be non-negative, but was ${corner.cornerSize} for corner $corner"
}
}
}
override fun createOutline(
size: Size,
layoutDirection: LayoutDirection,
density: Density,
): Outline {
if (size.width <= 0f || size.height <= 0f) return Outline.Rectangle(Rect.Zero)
val rect = Rect(0f, 0f, size.width, size.height)
val corners = listOf(topStart, topEnd, bottomEnd, bottomStart)
val radii = corners.map { convexCornerRadius(it, density, size) }
val concaveRadii = corners.map { concaveRadiusInPixels(it, density, size) }
val cutSizes = corners.map { cutSizeInPixels(it, density, size) }
// Fast path: rectangle if no corners have size
if (radii.all { it == CornerRadius.Zero } &&
concaveRadii.all { it == 0f } &&
cutSizes.all { it == 0f }
) {
return Outline.Rectangle(rect)
}
// Build base path. Always use a RoundRect to handle all corner types correctly.
val basePath = Path().apply {
addRoundRect(rect.toRoundRect(radii))
}
// Build concave and cut cutouts
val cutoutPath = Path().apply {
corners.forEachIndexed { index, corner ->
val position = CornerPosition.entries[index]
when (corner) {
is Corner.Concave -> {
val radius = concaveRadiusInPixels(corner, density, size)
if (radius > 0f) {
addConcaveOval(position, radius, size, layoutDirection)
}
}
is Corner.Cut -> {
val cutSize = cutSizeInPixels(corner, density, size)
if (cutSize > 0f) {
addCutTriangle(position, cutSize, size, layoutDirection)
}
}
else -> Unit
}
}
}
// Use generic outline if any cutouts or concave/cut corners exist
return if (cutoutPath.isEmpty) {
Outline.Rounded(rect.toRoundRect(radii))
} else {
Outline.Generic(Path.combine(PathOperation.Difference, basePath, cutoutPath))
}
}
private fun clampCornerSize(sizeInPx: Float, size: Size): Float =
minOf(sizeInPx, size.width / 2, size.height / 2)
private fun convexCornerRadius(corner: Corner, density: Density, size: Size): CornerRadius =
when (corner) {
is Corner.Rounded -> {
val radius = clampCornerSize(corner.cornerSize.toPx(size, density), size)
if (radius > 0f) CornerRadius(radius) else CornerRadius.Zero
}
is Corner.Cut, is Corner.Concave, is Corner.Sharp -> CornerRadius.Zero
}
private fun concaveRadiusInPixels(corner: Corner, density: Density, size: Size): Float =
when (corner) {
is Corner.Concave -> clampCornerSize(corner.cornerSize.toPx(size, density), size)
else -> 0f
}
private fun cutSizeInPixels(corner: Corner, density: Density, size: Size): Float =
when (corner) {
is Corner.Cut -> clampCornerSize(corner.cornerSize.toPx(size, density), size)
else -> 0f
}
private fun Path.addConcaveOval(
position: CornerPosition,
radius: Float,
size: Size,
layoutDirection: LayoutDirection,
) {
val (centerX, centerY) = position.getCenter(size, layoutDirection == LayoutDirection.Rtl)
val ovalRect = Rect(centerX - radius, centerY - radius, centerX + radius, centerY + radius)
addOval(ovalRect)
}
private fun Path.addCutTriangle(
position: CornerPosition,
cutSize: Float,
size: Size,
layoutDirection: LayoutDirection,
) {
val (cornerX, cornerY) = position.cornerXY(size, layoutDirection == LayoutDirection.Rtl)
when (position) {
TopStart -> {
moveTo(cornerX, cornerY)
lineTo(cornerX + cutSize, cornerY)
lineTo(cornerX, cornerY + cutSize)
close()
}
TopEnd -> {
moveTo(cornerX, cornerY)
lineTo(cornerX - cutSize, cornerY)
lineTo(cornerX, cornerY + cutSize)
close()
}
BottomEnd -> {
moveTo(cornerX, cornerY)
lineTo(cornerX, cornerY - cutSize)
lineTo(cornerX - cutSize, cornerY)
close()
}
BottomStart -> {
moveTo(cornerX, cornerY)
lineTo(cornerX + cutSize, cornerY)
lineTo(cornerX, cornerY - cutSize)
close()
}
}
}
private fun CornerPosition.cornerXY(size: Size, isRtl: Boolean): Pair<Float, Float> {
val x = when (this) {
TopStart, BottomStart -> if (isRtl) size.width else 0f
TopEnd, BottomEnd -> if (isRtl) 0f else size.width
}
val y = when (this) {
TopStart, TopEnd -> 0f
BottomStart, BottomEnd -> size.height
}
return x to y
}
private enum class CornerPosition(val baseX: Float, val baseY: Float) {
TopStart(0f, 0f),
TopEnd(1f, 0f),
BottomEnd(1f, 1f),
BottomStart(0f, 1f);
fun getCenter(size: Size, isRtl: Boolean): Pair<Float, Float> {
val x = if (isRtl) size.width - baseX * size.width else baseX * size.width
return x to baseY * size.height
}
}
}
/**
* Converts a [Rect] to a [RoundRect] using a list of four [CornerRadius] values in order:
* topStart, topEnd, bottomEnd, bottomStart.
*/
private fun Rect.toRoundRect(radii: List<CornerRadius>): RoundRect {
require(radii.size == 4) { "Radii list must contain exactly four elements" }
return RoundRect(
rect = this,
topLeft = radii[0],
topRight = radii[1],
bottomRight = radii[2],
bottomLeft = radii[3],
)
}
Job Offers
Why this works well with elevation
- When the result is a standard rounded rect, we return Outline.Rounded→ hardware accelerated shadows “just work”.
- When we must subtract geometry, we return Outline.Generic from a boolean Difference of paths. Compose correctly clips and casts shadows from the resulting outline, so elevations still look natural with mixed or concave corners.
Gotchas & tips
- Percent corner sizes are relative to the smaller side of the shape (Compose behavior).
- RTL: The helper flips Start/End correctly so your cuts/concaves land on the expected corners in RTL.
- Theme shapes: MaterialTheme.shapes.* is a Shape. If a theme provides a non-corner shape, accessing .topStart etc. won’t work. Safe-cast to CornerBasedShape or use fixed Dp defaults.
- Clamping: All sizes are clamped to min(width, height)/2 to avoid self-intersections.
- Performance: Most cases hit the fast paths (rectangle/rounded/cut); only truly mixed/concave corners build the boolean path.
Wrap-up
- Compose already gives you Rounded, Cut, and Sharp (via 0.dp or RectangleShape).
- With a tiny mapper + custom engine, you also get Concave and true mixes of rounded + cut in a single shape.
- Elevation and Material tokens keep working as expected.
I’ll continue playing with this and might create a small library if I think can be useful.
Thanks for reading!!

This article was previously published on proandroiddev.com.



