What You Will Build

The iconic Matrix digital rain effect: columns of green characters falling at different speeds against a black background, with the leading character in white and trailing characters fading out. This uses Canvas with native Android Paint for high-performance text rendering.

Why This Pattern Matters

This effect is a rite of passage for graphics programmers. Building it in Compose teaches you Canvas drawing, frame-rate animation loops with LaunchedEffect, and efficient text rendering with native Paint. The same techniques apply to any real-time visualization: stock tickers, data visualizations, and game backgrounds.

Step 1: Define the Drop Model

data class MatrixDrop(
    var y: Float,
    val speed: Float,
    val chars: List<Char>
)

Step 2: Initialize Columns

val config = LocalConfiguration.current
val columns = config.screenWidthDp / 16
val matrixChars =
    "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789".toList() +
    "characters from other scripts for variety".toList()

val drops = remember {
    (0 until columns).map {
        MatrixDrop(
            y = Random.nextFloat() * -500f,
            speed = Random.nextFloat() * 3f + 2f,
            chars = (0..20).map { matrixChars.random() }
        )
    }.toMutableStateList()
}

Step 3: Animation Loop

var tick by remember { mutableIntStateOf(0) }

LaunchedEffect(Unit) {
    while (true) {
        delay(50)  // ~20 FPS
        tick++
        drops.forEachIndexed { index, drop ->
            val newY = drop.y + drop.speed
            drops[index] = drop.copy(
                y = if (newY > config.screenHeightDp + 200)
                    Random.nextFloat() * -300f
                else newY
            )
        }
    }
}

Step 4: Canvas Rendering

Canvas(
    modifier = Modifier
        .fillMaxSize()
        .background(Color.Black)
) {
    drops.forEachIndexed { col, drop ->
        drop.chars.forEachIndexed { row, char ->
            val x = col * 16f * density
            val y = (drop.y + row * 18f) * density
            if (y > 0 && y < size.height) {
                val alpha = 1f -
                    (row.toFloat() / drop.chars.size)
                drawContext.canvas.nativeCanvas.drawText(
                    char.toString(), x, y,
                    android.graphics.Paint().apply {
                        color = android.graphics.Color.argb(
                            (alpha * 255).toInt()
                                .coerceIn(25, 255),
                            if (row == 0) 255 else 0,
                            255,
                            if (row == 0) 255 else 0
                        )
                        textSize = 14f * density
                        typeface =
                            android.graphics.Typeface.MONOSPACE
                    }
                )
            }
        }
    }
}

Tips and Pitfalls

  • Paint allocation: Creating Paint objects inside the draw loop is inefficient. In production, cache Paint objects and only change their alpha per character.
  • Frame rate: 50ms delay gives ~20 FPS, which is perfect for this effect. Higher rates waste battery without visual improvement.
  • White leading character: The row == 0 check makes the head of each column white, which is what gives the "falling" appearance.
  • Random reset: When a column falls below the screen, it resets to a random negative Y position. The negative range (-300f) creates natural staggering.

Min SDK: API 21+ with Compose 1.0+

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.