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+