What You Will Build

A scrollable list where items progressively shrink and fade as they get farther from the top of the visible area, creating an elastic, perspective-like depth effect. Items near the top appear full-size and opaque, while items farther down appear smaller and more transparent.

Why Scroll-Based Transforms Create Premium Feel

Scroll-driven visual effects are what separate polished apps from basic ones. Apple Music, Spotify, and premium news apps all use scroll-position-dependent transforms to create depth and focus attention. The graphicsLayer modifier with LazyListState gives you this power in Compose.

Key Compose Concepts

  • rememberLazyListState() for tracking scroll position.
  • graphicsLayer for GPU-accelerated scale and alpha transforms.
  • Distance-from-top calculations for progressive visual scaling.

Step 1: Set Up the List State

val items = (1..30).toList()
val listState = rememberLazyListState()
val colors = listOf(
    Color(0xFFFF6B6B), Color(0xFF4ECDC4),
    Color(0xFF45B7D1), Color(0xFFFFA07A),
    Color(0xFF98D8C8)
)

Step 2: Calculate Distance-Based Transforms

For each item, compute how far it is from the top of the visible list, then derive scale and alpha values:

items(items) { index ->
    val firstVisible = listState.firstVisibleItemIndex
    val offset = listState.firstVisibleItemScrollOffset
    val distanceFromTop = (index - 1 - firstVisible) +
        (1f - offset / 500f)

    val scale = (1f - (distanceFromTop * 0.02f)
        .coerceIn(0f, 0.15f))
    val alpha = (1f - (distanceFromTop * 0.05f)
        .coerceIn(0f, 0.5f))

Step 3: Apply with graphicsLayer

Box(
    Modifier
        .fillMaxWidth()
        .height(80.dp)
        .graphicsLayer(
            scaleX = scale,
            scaleY = scale,
            alpha = alpha
        )
        .clip(RoundedCornerShape(16.dp))
        .background(
            colors[(index - 1) % colors.size]
                .copy(alpha = 0.8f)
        )
        .padding(16.dp)
) {
    Text(
        "Item $index",
        color = Color.White,
        style = MaterialTheme.typography.titleMedium
    )
}

The Complete Implementation

@Composable
fun ElasticScrollScreen() {
    val items = (1..30).toList()
    val listState = rememberLazyListState()
    val colors = listOf(
        Color(0xFFFF6B6B), Color(0xFF4ECDC4),
        Color(0xFF45B7D1), Color(0xFFFFA07A),
        Color(0xFF98D8C8)
    )

    LazyColumn(
        state = listState,
        modifier = Modifier
            .fillMaxSize()
            .padding(16.dp),
        verticalArrangement = Arrangement.spacedBy(12.dp)
    ) {
        items(items) { index ->
            val firstVisible = listState.firstVisibleItemIndex
            val offset = listState.firstVisibleItemScrollOffset
            val distanceFromTop = (index - 1 - firstVisible) +
                (1f - offset / 500f)
            val scale = (1f - (distanceFromTop * 0.02f)
                .coerceIn(0f, 0.15f))
            val alpha = (1f - (distanceFromTop * 0.05f)
                .coerceIn(0f, 0.5f))

            Box(
                Modifier
                    .fillMaxWidth()
                    .height(80.dp)
                    .graphicsLayer(
                        scaleX = scale,
                        scaleY = scale,
                        alpha = alpha
                    )
                    .clip(RoundedCornerShape(16.dp))
                    .background(
                        colors[(index - 1) % colors.size]
                            .copy(alpha = 0.8f)
                    )
                    .padding(16.dp)
            ) {
                Text(
                    "Item $index",
                    color = Color.White,
                    style = MaterialTheme.typography.titleMedium
                )
            }
        }
    }
}

Tips and Pitfalls

  • graphicsLayer is GPU-accelerated and does not trigger recomposition, making it ideal for scroll-driven animations.
  • coerceIn prevents extreme values: Without clamping, items far off-screen could have negative scale or alpha, causing visual artifacts.
  • Scale multiplier of 0.02f creates a subtle effect. Increase to 0.05f for a more dramatic shrinking. Alpha multiplier of 0.05f fades gradually; increase for faster fade-out.
  • firstVisibleItemScrollOffset / 500f creates smooth sub-item interpolation. The divisor should approximate your item height in pixels for smooth behavior.

Minimum SDK: API 24+ with Compose BOM 2024.01+

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.