
Adds rubbery per-item elasticity to lazy lists using per-frame graphicsLayer transforms; exposes velocity-aware item data, elastic strength presets, and customizable per-item effect lambdas.
Small Compose lib that makes LazyColumn / LazyRow feel a bit rubbery. Scroll fast and the items at the front lag behind a little, like they're on a stretchy band. Nothing weird happens to layout or measuring, it's all just graphicsLayer under the hood.
Published to Maven Central, so no extra repositories are needed beyond the usual mavenCentral().
Kotlin Multiplatform — add it to commonMain:
kotlin {
sourceSets {
commonMain.dependencies {
implementation("io.github.makzimi:scroll-effects:0.2.0")
}
}
}Android-only (or any single-platform module):
dependencies {
implementation("io.github.makzimi:scroll-effects:0.2.0")
}Targets: Android, iOS, desktop (JVM), and web (Wasm/JS).
Swap your LazyColumn / LazyRow for the wrapper, toss in a scrollEffect lambda, done:
ScrollEffectLazyColumn(
scrollEffect = { item -> elastic(item, ElasticStrength.Normal) }
) {
items(myList) { MyCard(it) }
}Don't want the effect? Pass null or just leave it out. You're back to a plain lazy list, no overhead.
The built-in elastic() is just one thing you can do. The scrollEffect lambda runs per visible item on every frame, and this inside it is a GraphicsLayerScope, so you can do whatever you want with it.
ScrollEffectLazyColumn(
// lambda runs per-item, every frame
scrollEffect = { item ->
// use the prebuilt rubber band
elastic(item)
// or roll your own with anything from item / item.velocity
// (it's a GraphicsLayerScope, so all the usual stuff is here)
alpha = 1f - item.velocity.stretch * 0.3f
rotationZ = item.velocity.direction * 2f
}
) {
items(myList) { MyCard(it) }
}You can also mix: call elastic(item) for the base feel and then layer your own tweaks on top.
Short answer: the effect has to touch every item, not the list as a whole. To pull that off we have to sit between you and LazyListScope and wrap each item in its own graphicsLayer. A modifier on the list can't do that, and making you glue a modifier onto every item by hand would be a pain. The lambda also runs on a little custom scope (ScrollEffectScope) that bundles GraphicsLayerScope with list-aware stuff and shortcuts like elastic(), which a regular modifier just can't give you.
Same as LazyColumn / LazyRow. All the usual params go straight through. The only extra thing is:
scrollEffect: (ScrollEffectScope.(item: ScrollingItemData) -> Unit)? = nullIf it's null, we don't do anything fancy, just call the normal composable.
Whatever this is inside your scrollEffect lambda. It's a GraphicsLayerScope, so all the usual toys are there (alpha, scaleX, rotationZ, ...), plus this one shortcut:
fun elastic(
item: ScrollingItemData,
strength: ElasticStrength = ElasticStrength.Normal,
maxTranslationDp: Float = 150f,
)One of these lands in your lambda for every visible item on every frame.
| Property | Type | What it is |
|---|---|---|
index |
Int |
Which spot this item sits in |
velocity |
ScrollVelocity |
What the list's doing right now |
distanceFromDragged |
Int |
How far from the item under your finger (0 means that one, positive means further down, negative means further back) |
Quick snapshot of how the list is moving. The built-in elastic() only looks at lagPx, the rest are there so you have stuff to work with when you write your own effects.
| Property | Type | What it is |
|---|---|---|
lagPx |
Float |
Signed lag in pixels. This is what elastic() uses to do its thing |
rawPxPerSec |
Float |
Straight-from-VelocityTracker speed, px/s |
stretch |
Float |
Same thing squashed into [0, 1] with tanh
|
direction |
Int |
+1 = forward, -1 = back, 0 = not moving |
isDragging |
Boolean |
Finger's still on the screen |
isFlinging |
Boolean |
Finger's off, list's still sliding |
How floppy you want the rubber.
| Variant | Feel | Gap size |
|---|---|---|
Hard |
Stiff, barely there | Small |
Normal |
Just right | Medium |
Loose |
Soft, really obvious | Big |
The repo ships a sample app that shows the effect across platforms. The UI is shared
in sample-common; each platform just hosts it.
| Platform | How to run |
|---|---|
| Android | Open in Android Studio and run the sample-android app, or ./gradlew :sample-android:installDebug
|
| iOS | Open iosApp/iosApp.xcodeproj in Xcode and run. To run on a device, set your Apple Team ID in iosApp/Configuration/Config.xcconfig
|
| Desktop (JVM) | ./gradlew :sample-common:run |
| Web (Wasm/JS) |
./gradlew :sample-common:wasmJsBrowserDevelopmentRun, then open http://localhost:8080
|
Web needs a browser with WebAssembly GC support (Chrome/Edge 119+, Firefox 120+, Safari 18+).
Toggle the Off / On switch at the top of any list screen to compare a plain list with the elastic effect.
MIT License
Copyright (c) 2026 Maxim Kachinkin
Released under the MIT License — do what you want, just keep the notice.
Small Compose lib that makes LazyColumn / LazyRow feel a bit rubbery. Scroll fast and the items at the front lag behind a little, like they're on a stretchy band. Nothing weird happens to layout or measuring, it's all just graphicsLayer under the hood.
Published to Maven Central, so no extra repositories are needed beyond the usual mavenCentral().
Kotlin Multiplatform — add it to commonMain:
kotlin {
sourceSets {
commonMain.dependencies {
implementation("io.github.makzimi:scroll-effects:0.2.0")
}
}
}Android-only (or any single-platform module):
dependencies {
implementation("io.github.makzimi:scroll-effects:0.2.0")
}Targets: Android, iOS, desktop (JVM), and web (Wasm/JS).
Swap your LazyColumn / LazyRow for the wrapper, toss in a scrollEffect lambda, done:
ScrollEffectLazyColumn(
scrollEffect = { item -> elastic(item, ElasticStrength.Normal) }
) {
items(myList) { MyCard(it) }
}Don't want the effect? Pass null or just leave it out. You're back to a plain lazy list, no overhead.
The built-in elastic() is just one thing you can do. The scrollEffect lambda runs per visible item on every frame, and this inside it is a GraphicsLayerScope, so you can do whatever you want with it.
ScrollEffectLazyColumn(
// lambda runs per-item, every frame
scrollEffect = { item ->
// use the prebuilt rubber band
elastic(item)
// or roll your own with anything from item / item.velocity
// (it's a GraphicsLayerScope, so all the usual stuff is here)
alpha = 1f - item.velocity.stretch * 0.3f
rotationZ = item.velocity.direction * 2f
}
) {
items(myList) { MyCard(it) }
}You can also mix: call elastic(item) for the base feel and then layer your own tweaks on top.
Short answer: the effect has to touch every item, not the list as a whole. To pull that off we have to sit between you and LazyListScope and wrap each item in its own graphicsLayer. A modifier on the list can't do that, and making you glue a modifier onto every item by hand would be a pain. The lambda also runs on a little custom scope (ScrollEffectScope) that bundles GraphicsLayerScope with list-aware stuff and shortcuts like elastic(), which a regular modifier just can't give you.
Same as LazyColumn / LazyRow. All the usual params go straight through. The only extra thing is:
scrollEffect: (ScrollEffectScope.(item: ScrollingItemData) -> Unit)? = nullIf it's null, we don't do anything fancy, just call the normal composable.
Whatever this is inside your scrollEffect lambda. It's a GraphicsLayerScope, so all the usual toys are there (alpha, scaleX, rotationZ, ...), plus this one shortcut:
fun elastic(
item: ScrollingItemData,
strength: ElasticStrength = ElasticStrength.Normal,
maxTranslationDp: Float = 150f,
)One of these lands in your lambda for every visible item on every frame.
| Property | Type | What it is |
|---|---|---|
index |
Int |
Which spot this item sits in |
velocity |
ScrollVelocity |
What the list's doing right now |
distanceFromDragged |
Int |
How far from the item under your finger (0 means that one, positive means further down, negative means further back) |
Quick snapshot of how the list is moving. The built-in elastic() only looks at lagPx, the rest are there so you have stuff to work with when you write your own effects.
| Property | Type | What it is |
|---|---|---|
lagPx |
Float |
Signed lag in pixels. This is what elastic() uses to do its thing |
rawPxPerSec |
Float |
Straight-from-VelocityTracker speed, px/s |
stretch |
Float |
Same thing squashed into [0, 1] with tanh
|
direction |
Int |
+1 = forward, -1 = back, 0 = not moving |
isDragging |
Boolean |
Finger's still on the screen |
isFlinging |
Boolean |
Finger's off, list's still sliding |
How floppy you want the rubber.
| Variant | Feel | Gap size |
|---|---|---|
Hard |
Stiff, barely there | Small |
Normal |
Just right | Medium |
Loose |
Soft, really obvious | Big |
The repo ships a sample app that shows the effect across platforms. The UI is shared
in sample-common; each platform just hosts it.
| Platform | How to run |
|---|---|
| Android | Open in Android Studio and run the sample-android app, or ./gradlew :sample-android:installDebug
|
| iOS | Open iosApp/iosApp.xcodeproj in Xcode and run. To run on a device, set your Apple Team ID in iosApp/Configuration/Config.xcconfig
|
| Desktop (JVM) | ./gradlew :sample-common:run |
| Web (Wasm/JS) |
./gradlew :sample-common:wasmJsBrowserDevelopmentRun, then open http://localhost:8080
|
Web needs a browser with WebAssembly GC support (Chrome/Edge 119+, Firefox 120+, Safari 18+).
Toggle the Off / On switch at the top of any list screen to compare a plain list with the elastic effect.
MIT License
Copyright (c) 2026 Maxim Kachinkin
Released under the MIT License — do what you want, just keep the notice.