How to Ripple Outside of Compose Material

There is more to life than Material Design.

After all, it is a common complaint, at least here in the US, that designers design around iOS. Last I checked, iOS designs are not very Material. So, it stands to reason that Android developers using Compose will need to deviate from Material Design in many cases.

In theory, developers would create custom design systems from the ground up. In practice, I suspect that most developers use Compose Material or Material3 and try to implement the designs that way. This can work, but sometimes developers wind up having to resort to some fairly significant hacks in order to work around cases where Material’s opinions deviate from the designers’ opinions.

I am working on a project where I am creating a from-scratch custom design system. Along the way, I will try to point out how I am filling in various Compose gaps, where Material provides a solution but “you’re on your own” for a custom design system.


This time, let’s look at indications. Indications are how a clickable composable lets the user know that, indeed, they clicked the composable. Material Design calls for a ripple effect. Compose Material offers the ripple.

Your graphic designer probably is not designing indications for you, but you are going to need some sort of touch feedback. The ripple is a perfectly cromulent option, but the ripple is a Compose Material thing and will not be supplied “out of the box” for non-Material design systems.

The good news is that a lot of the ripple logic resides in a standalone library, one without other Material dependencies. This makes it reasonable for use in a non-Material Compose app. The bad news is that the documentation on how to actually apply that library is limited.

So, here is how you can do it.

First, you will need to add that library. That is androidx.compose.material:material-ripple, and the version I wrote this blog post around is 1.7.2:

composeRipple = "1.7.2"
compose-ripple = { group = "androidx.compose.material", name = "material-ripple", version.ref = "composeRipple" }
dependencies {
    implementation(libs.compose.ripple)
    // TODO other cool libraries go here
}

The documentation tells you “oh, do what Material3 does to provide the ripple”. That is contained mostly in Ripple.kt. So, copy its contents into your own project (be sure to abide by the license!). This will require you to also grab these StateTokens defined elsewhere:

internal object StateTokens {
    const val DraggedStateLayerOpacity = 0.16f
    const val FocusStateLayerOpacity = 0.1f
    const val HoverStateLayerOpacity = 0.08f
    const val PressedStateLayerOpacity = 0.1f
}

For the file version I used, IIRC there is only one real connection to the rest of Material3 in the file: a reference to currentValueOf(LocalContentColor). This is only needed if you do not supply a color directly when creating the ripple or via a LocalRippleConfiguration composition local. In my case, I was perfectly happy with two options for providing a color and did not need a third, so I swapped currentValueOf(LocalContentColor) with a RuntimeException:

    private fun attachNewRipple() {
        val calculateColor = ColorProducer {
            val userDefinedColor = color()
            if (userDefinedColor.isSpecified) {
                userDefinedColor
            } else {
                // If this is null, the ripple will be removed, so this should always be non-null in
                // normal use
                val rippleConfiguration = currentValueOf(LocalRippleConfiguration)
                if (rippleConfiguration?.color?.isSpecified == true) {
                    rippleConfiguration.color
                } else {
                    // currentValueOf(LocalContentColor)
                    throw RuntimeException("missing color for ripple")
                }
            }
        }

That should be all the changes that are needed… at least for the version of Ripple.kt that I used. It is possible that I forgot something, in which case I apologize.

Then, to actually apply the ripple, set the LocalIndication composition local to a ripple() that you create from the Ripple.kt code that you copied and revised:

CompositionLocalProvider(LocalIndication provides ripple(color = Color.White)) {
	// TODO cool stuff goes here
}

If you want to be able to change the ripple color without replacing the entire ripple, rather than provide the color to ripple(), you could leave that parameter out and also define a LocalRippleConfiguration:

CompositionLocalProvider(
	LocalIndication provides ripple(),
	LocalRippleConfiguration provides RippleConfiguration(color = Color.White)
) {
	// TODO cool stuff goes here
}

RippleConfiguration also lets you control the alpha values used in the ripple effect, overriding the defaults coming from StateTokens.

No matter how you provide the color, you will need to do that separately if you need different colors for different themes, such as light and dark.

If you are using ComposeTheme for your custom design system, there is an indication property that you can set in your buildComposeTheme() builder:

private val AwesomeDarkTheme = buildComposeTheme {
    name = "AwesomeDarkTheme"
    indication = ripple(color = Color.White)

    // TODO define the rest of the theme, which hopefully also is cool
}

ComposeTheme will take care of setting LocalIndication for you.

Once LocalIndication is set, clickable() and other modifiers and code will apply it automatically, so your ripple should show up. Alternatively, you can provide a ripple() directly as the indication to clickable(), perhaps for cases where you want a different implementation than the one from LocalIndication.

If you want an indication, but you want to do something other than a ripple… look at the implementation of ripple(), especially the RippleNodeFactory and DelegatingThemeAwareRippleNode, which eventually link into code from that material-ripple library mentioned earlier.