Skip to content

Instantly share code, notes, and snippets.

@indexer
Last active March 25, 2026 00:54
Show Gist options
  • Select an option

  • Save indexer/eb75a117ed36be4fd86a53c801af197b to your computer and use it in GitHub Desktop.

Select an option

Save indexer/eb75a117ed36be4fd86a53c801af197b to your computer and use it in GitHub Desktop.
Custom Cutout Card Shapes in Jetpack Compose

Custom Cutout Card Shapes in Jetpack Compose

This guide explains how to build custom cutout card shapes in Jetpack Compose using Shape, Path, and arcTo.

The example includes:

  • TopCardCutoutShape
  • BottomCardCutoutShape
  • a demo composable showing how both shapes fit together

What is Shape in Compose?

In Jetpack Compose, a Shape defines how a composable is clipped.

To create a custom shape, implement the Shape interface and override createOutline().

private class TopCardCutoutShape(
    private val cutoutRadius: Dp,
    private val cornerRadius: Dp
) : Shape {
    override fun createOutline(
        size: Size,
        layoutDirection: LayoutDirection,
        density: Density
    ): Outline {
        // Path logic goes here...
    }
}

Important parameters

  • size: width and height of the composable
  • density: used to convert Dp values into pixels
  • layoutDirection: useful if your shape changes for RTL layouts

Understanding Path.arcTo

arcTo() draws a curved arc based on a bounding Rect.

Parameters

  • rect: the rectangle containing the full oval/circle
  • startAngleDegrees:
    • = right side
    • 90° = bottom
    • 180° = left side
    • 270° = top
  • sweepAngleDegrees:
    • positive = clockwise
    • negative = counter-clockwise

So if you want a semicircle cut upward from the bottom edge, you can start at the right side and sweep counter-clockwise:

arcTo(
    rect = Rect(...),
    startAngleDegrees = 0f,
    sweepAngleDegrees = -180f,
    forceMoveTo = false
)

Visual idea

Top card cutout

The top card has:

  • rounded top-left corner
  • rounded top-right corner
  • a semicircular cutout at the bottom center
╭──────────────╮
│              │
│              │
│      ⌒       │
└──────────────┘

Bottom card cutout

The bottom card is the mirrored version:

  • a semicircular dip at the top center
  • rounded bottom-left corner
  • rounded bottom-right corner
┌──────────────┐
│      ⌒       │
│              │
│              │
╰──────────────╯

When stacked together, the cutouts visually align.


Full Kotlin File

package com.example.cutoutshape

import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Outline
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp

private class TopCardCutoutShape(
    private val cutoutRadius: Dp,
    private val cornerRadius: Dp
) : Shape {

    override fun createOutline(
        size: Size,
        layoutDirection: LayoutDirection,
        density: Density
    ): Outline {
        val cutoutRadiusPx = with(density) { cutoutRadius.toPx() }
        val cornerRadiusPx = with(density) { cornerRadius.toPx() }
        val centerX = size.width / 2f

        val path = Path().apply {
            moveTo(0f, cornerRadiusPx)

            arcTo(
                rect = Rect(
                    left = 0f,
                    top = 0f,
                    right = cornerRadiusPx * 2,
                    bottom = cornerRadiusPx * 2
                ),
                startAngleDegrees = 180f,
                sweepAngleDegrees = 90f,
                forceMoveTo = false
            )

            lineTo(size.width - cornerRadiusPx, 0f)

            arcTo(
                rect = Rect(
                    left = size.width - cornerRadiusPx * 2,
                    top = 0f,
                    right = size.width,
                    bottom = cornerRadiusPx * 2
                ),
                startAngleDegrees = 270f,
                sweepAngleDegrees = 90f,
                forceMoveTo = false
            )

            lineTo(size.width, size.height)
            lineTo(centerX + cutoutRadiusPx, size.height)

            arcTo(
                rect = Rect(
                    left = centerX - cutoutRadiusPx,
                    top = size.height - cutoutRadiusPx,
                    right = centerX + cutoutRadiusPx,
                    bottom = size.height + cutoutRadiusPx
                ),
                startAngleDegrees = 0f,
                sweepAngleDegrees = -180f,
                forceMoveTo = false
            )

            lineTo(0f, size.height)
            close()
        }

        return Outline.Generic(path)
    }
}

private class BottomCardCutoutShape(
    private val cutoutRadius: Dp,
    private val cornerRadius: Dp
) : Shape {

    override fun createOutline(
        size: Size,
        layoutDirection: LayoutDirection,
        density: Density
    ): Outline {
        val cutoutRadiusPx = with(density) { cutoutRadius.toPx() }
        val cornerRadiusPx = with(density) { cornerRadius.toPx() }
        val centerX = size.width / 2f

        val path = Path().apply {
            moveTo(0f, 0f)

            lineTo(centerX - cutoutRadiusPx, 0f)

            arcTo(
                rect = Rect(
                    left = centerX - cutoutRadiusPx,
                    top = -cutoutRadiusPx,
                    right = centerX + cutoutRadiusPx,
                    bottom = cutoutRadiusPx
                ),
                startAngleDegrees = 180f,
                sweepAngleDegrees = -180f,
                forceMoveTo = false
            )

            lineTo(size.width, 0f)
            lineTo(size.width, size.height - cornerRadiusPx)

            arcTo(
                rect = Rect(
                    left = size.width - cornerRadiusPx * 2,
                    top = size.height - cornerRadiusPx * 2,
                    right = size.width,
                    bottom = size.height
                ),
                startAngleDegrees = 0f,
                sweepAngleDegrees = 90f,
                forceMoveTo = false
            )

            lineTo(cornerRadiusPx, size.height)

            arcTo(
                rect = Rect(
                    left = 0f,
                    top = size.height - cornerRadiusPx * 2,
                    right = cornerRadiusPx * 2,
                    bottom = size.height
                ),
                startAngleDegrees = 90f,
                sweepAngleDegrees = 90f,
                forceMoveTo = false
            )

            lineTo(0f, 0f)
            close()
        }

        return Outline.Generic(path)
    }
}

@Composable
fun CutoutCardsDemo() {
    val cutoutRadius = 28.dp
    val cornerRadius = 24.dp

    Column(
        modifier = Modifier
            .fillMaxSize()
            .background(Color(0xFFF5F5F5))
            .padding(24.dp),
        verticalArrangement = Arrangement.Center,
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Box(
            modifier = Modifier
                .size(width = 280.dp, height = 160.dp)
                .clip(
                    TopCardCutoutShape(
                        cutoutRadius = cutoutRadius,
                        cornerRadius = cornerRadius
                    )
                )
                .background(Color(0xFF6750A4)),
            contentAlignment = Alignment.Center
        ) {
            Text(
                text = "Top Card",
                color = Color.White,
                style = MaterialTheme.typography.titleMedium
            )
        }

        Spacer(modifier = Modifier.height(12.dp))

        Box(
            modifier = Modifier
                .size(width = 280.dp, height = 160.dp)
                .clip(
                    BottomCardCutoutShape(
                        cutoutRadius = cutoutRadius,
                        cornerRadius = cornerRadius
                    )
                )
                .background(Color(0xFF03DAC5)),
            contentAlignment = Alignment.Center
        ) {
            Text(
                text = "Bottom Card",
                color = Color.Black,
                style = MaterialTheme.typography.titleMedium
            )
        }
    }
}

@Preview(showBackground = true, backgroundColor = 0xFFFFFFFF)
@Composable
private fun CutoutCardsDemoPreview() {
    MaterialTheme {
        Surface {
            CutoutCardsDemo()
        }
    }
}

How the top cutout works

Inside TopCardCutoutShape, the key part is:

lineTo(centerX + cutoutRadiusPx, size.height)

arcTo(
    rect = Rect(
        left = centerX - cutoutRadiusPx,
        top = size.height - cutoutRadiusPx,
        right = centerX + cutoutRadiusPx,
        bottom = size.height + cutoutRadiusPx
    ),
    startAngleDegrees = 0f,
    sweepAngleDegrees = -180f,
    forceMoveTo = false
)

Why this works

The rectangle defines a circle centered on the bottom edge.

  • left = centerX - radius
  • right = centerX + radius
  • top = bottomEdge - radius
  • bottom = bottomEdge + radius

Because the circle center sits on the bottom edge, only the upper half of the circle cuts into the card.

  • startAngleDegrees = 0f starts at the right side
  • sweepAngleDegrees = -180f draws counter-clockwise to the left side

That creates a smooth upward bite.


How the bottom cutout works

Inside BottomCardCutoutShape, the main part is:

lineTo(centerX - cutoutRadiusPx, 0f)

arcTo(
    rect = Rect(
        left = centerX - cutoutRadiusPx,
        top = -cutoutRadiusPx,
        right = centerX + cutoutRadiusPx,
        bottom = cutoutRadiusPx
    ),
    startAngleDegrees = 180f,
    sweepAngleDegrees = -180f,
    forceMoveTo = false
)

Why this works

Now the circle center sits on the top edge of the card.

  • top = -cutoutRadiusPx
  • bottom = cutoutRadiusPx

This places half of the circle above the composable and half below it.

  • startAngleDegrees = 180f starts from the left side
  • sweepAngleDegrees = -180f draws the arc across the bottom half

That creates a downward dip into the card.


Angle cheat sheet

Use this mental model for arcTo:

        270°
          ↑
180°  ←  circle  →  0°
          ↓
         90°

Common examples

Top-left rounded corner

arcTo(
    rect = Rect(...),
    startAngleDegrees = 180f,
    sweepAngleDegrees = 90f,
    forceMoveTo = false
)

Top-right rounded corner

arcTo(
    rect = Rect(...),
    startAngleDegrees = 270f,
    sweepAngleDegrees = 90f,
    forceMoveTo = false
)

Bottom-right rounded corner

arcTo(
    rect = Rect(...),
    startAngleDegrees = 0f,
    sweepAngleDegrees = 90f,
    forceMoveTo = false
)

Bottom-left rounded corner

arcTo(
    rect = Rect(...),
    startAngleDegrees = 90f,
    sweepAngleDegrees = 90f,
    forceMoveTo = false
)

Customization ideas

You can adjust the look by changing:

  • cutoutRadius
    • larger value = deeper and wider cutout
  • cornerRadius
    • larger value = softer outer corners
  • card height and width
    • changes the visual balance of the cutout

Example:

val cutoutRadius = 36.dp
val cornerRadius = 20.dp

When to use this pattern

This shape style works well for:

  • ticket-style cards
  • stacked profile cards
  • onboarding UI
  • animated dashboard sections
  • custom AI or assistant panels
  • visually connected components

Notes

  • forceMoveTo = false is important so the arc connects smoothly to the current path.
  • Always convert Dp to pixels inside createOutline().
  • Custom shapes are easier to debug with strong background contrast.

Summary

You built two custom Compose shapes:

  • TopCardCutoutShape: rounded top corners with a bottom-center cutout
  • BottomCardCutoutShape: top-center cutout with rounded bottom corners

The key idea is that arcTo() does not create the correct curve automatically.
You must place the bounding Rect correctly and choose the right start angle and sweep direction.

Once that clicks, custom Compose shapes become much easier to design.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment