What You Will Build

A button with a live sine-wave animation drawn on a Canvas. The wave runs continuously across the button surface, and when you press the button the wave amplitude increases for a tactile feel. This creates an organic, fluid effect suitable for music apps, subscription CTAs, or onboarding screens.

Why This Pattern Matters

Canvas drawing in Compose gives you pixel-level control that standard composables cannot match. The liquid button teaches you how to combine rememberInfiniteTransition for perpetual phase animation with touch-driven animateFloatAsState for interactive feedback.

Key Compose Concepts

  • Canvas composable for custom drawing
  • Path API with sine wave math
  • detectTapGestures for press/release interaction
  • rememberTextMeasurer to draw text on Canvas

The Liquid Button

@Composable
fun LiquidButton(text: String, color1: Color, color2: Color) {
    var isPressed by remember { mutableStateOf(false) }
    val infiniteTransition = rememberInfiniteTransition(label = "wave")
    val wavePhase by infiniteTransition.animateFloat(
        initialValue = 0f,
        targetValue = (2 * Math.PI).toFloat(),
        animationSpec = infiniteRepeatable(
            tween(2000, easing = LinearEasing)
        ),
        label = "phase"
    )
    val waveAmplitude by animateFloatAsState(
        targetValue = if (isPressed) 8f else 3f,
        animationSpec = spring(dampingRatio = 0.5f),
        label = "amp"
    )
    val textMeasurer = rememberTextMeasurer()

    Canvas(
        modifier = Modifier
            .width(220.dp)
            .height(56.dp)
            .pointerInput(Unit) {
                detectTapGestures(
                    onPress = {
                        isPressed = true
                        tryAwaitRelease()
                        isPressed = false
                    }
                )
            }
    ) {
        val w = size.width
        val h = size.height

        // Main rounded rectangle
        drawRoundRect(
            color = color1,
            cornerRadius = CornerRadius(28.dp.toPx()),
            size = size
        )

        // Wave overlay using Path
        val path = Path().apply {
            moveTo(0f, h * 0.5f)
            var x = 0f
            while (x <= w) {
                val y = h * 0.5f +
                    sin(x * 0.03f + wavePhase) *
                    waveAmplitude.dp.toPx()
                lineTo(x, y)
                x += 2f
            }
            lineTo(w, h)
            lineTo(0f, h)
            close()
        }
        drawPath(path, color = color2)

        // Centered text
        val textLayout = textMeasurer.measure(
            text,
            style = TextStyle(
                color = Color.White,
                fontWeight = FontWeight.Bold,
                fontSize = 16.sp
            )
        )
        drawText(
            textLayout,
            topLeft = Offset(
                (w - textLayout.size.width) / 2f,
                (h - textLayout.size.height) / 2f
            )
        )
    }
}

Tips and Pitfalls

  • Wave frequency: The 0.03f multiplier on x controls how many wave peaks appear. Higher values produce tighter waves.
  • Use tryAwaitRelease() inside onPress to properly track press-and-release cycles.
  • rememberTextMeasurer is essential for drawing text on Canvas with proper layout measurement.
  • Step size of 2f in the while loop balances visual smoothness against draw call count.

Get New Tutorials by Email

No spam. Just clear, practical breakdowns you can apply right away.

Enjoy this tutorial?

Get new practical tech tutorials in your inbox.