
Reduce-motion aware UI animation primitives: arrival-tracking staggered entrances that don't replay on scroll, global motion scale, scaled tweens, springs, breathing and shimmer helpers.
Reduce-motion-safe animation primitives for Compose Multiplatform. Android and iOS.
Two problems, both small, both easy to get wrong:
LazyColumn disposes a row
that scrolls out of view and composes it again on the way back, taking any
remember { false } entrance flag with it. The animation runs again, and the list
never stops twitching.This library is the answer to both, in about 300 lines.
| Android | iOS |
|---|---|
![]() |
![]() |
Same code both times — sample/shared is one App() composable. The list arrives
staggered, scrolls away and back without replaying, replays when asked to, and then
holds perfectly still once reduce motion is on: the entrance snaps in, breathing()
stops at its end value, and the shimmer becomes a plain bar because sweeping() returns
null. Nothing in the sample checks a reduce-motion flag; it provides the scale once.
Run it yourself: sample/README.md.
| Target | Reduce-motion detected from |
|---|---|
| Android | Animator duration scale — what Accessibility → Remove animations writes to |
iOS (iosArm64, iosSimulatorArm64) |
UIAccessibilityIsReduceMotionEnabled |
| JVM / desktop | Nothing — see below |
Desktop has no portable reduce-motion setting, so prefersReducedMotion() answers
false there. That does not leave you stuck: every primitive here multiplies by
LocalMotionScale, so provide the scale yourself and the whole library obeys it.
rememberMotionScale() is a convenience for the two platforms that can answer for
themselves, not the only way in.
No iosX64: Compose Multiplatform 1.11.x publishes no artifacts for the Intel-Mac
simulator, so the dependency could not resolve there anyway.
Published to Maven Central as com.sinebloc:quiet-motion.
// build.gradle.kts
kotlin {
sourceSets {
commonMain.dependencies {
implementation("com.sinebloc:quiet-motion:0.1.0")
}
}
}Version catalog, if you keep one:
# gradle/libs.versions.toml
[libraries]
quiet-motion = { module = "com.sinebloc:quiet-motion", version = "0.1.0" }Compose pulls transitive androidx artifacts, so your repositories need google()
alongside mavenCentral() — as any Compose Multiplatform consumer does:
// settings.gradle.kts
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
}
}Building against an unreleased change is ./gradlew publishToMavenLocal here, plus
mavenLocal() in the consuming build. Cutting a release is RELEASING.md.
Everything here multiplies by one composition local. Provide it near the root of your tree and you are done — there is no second thing to remember:
@Composable
fun AppTheme(content: @Composable () -> Unit) {
CompositionLocalProvider(LocalMotionScale provides rememberMotionScale()) {
content()
}
}rememberMotionScale() returns 0f when the platform asks for reduced motion and 1f
otherwise. On iOS that is UIAccessibilityIsReduceMotionEnabled; on Android it is the
animator duration scale, which is what Accessibility → Remove animations writes to
and the one users actually reach for.
A scale rather than a boolean, deliberately: tweenScaled multiplies, so a future
half-speed preference is a different number here and no second code path anywhere else.
Hold the record of what has already been seen above the list that draws it:
val arrivals = rememberArrivals()
LazyColumn {
itemsIndexed(rows) { index, row ->
Card(Modifier.arriving(arrivals, row.id, delayMillis = QuietMotion.stagger(index)))
}
}A row's first appearance fades and lifts into place. Its second appearance — after
scrolling away and back — draws in place with no animation and holds no animation state
at all. rememberArrivals(selectedTab) makes a tab change count as arriving again,
while scrolling still does not.
arrivalProgress(...) gives you the same 0→1 value when it has to drive layout rather
than opacity. It costs a recomposition per frame, because the value is read during
composition; reach for Modifier.arriving unless something has to change size.
tweenMotion(...) |
The standard tween, already scaled. |
tweenScaled(scale, ...) |
Same, for callers that cannot read a composition local — a NavHost's transition slots and an AnimatedContent's transitionSpec are not composable lambdas. Read LocalMotionScale in your own body and hand the value down. |
springMotion(...) |
Critically damped by default. A plain snap() under reduce-motion. |
scaledMillis(ms) |
A duration, scaled, for when you need the number itself. |
breathing(from, to, ...) |
A value oscillating for something that waits. Returns to and starts no animation under reduce-motion. |
sweeping(...) |
A 0→1 loop for a shimmer. null under reduce-motion — a sweep frozen halfway is a smudge, not a calmer sweep, so the caller draws nothing. |
breathing and sweeping exist because of a bug worth naming. The obvious way to make
an infinite transition respect the scale is to multiply its duration and clamp it to at
least a millisecond. When the scale is zero that yields a one-millisecond loop — a
strobe at frame rate, delivered to precisely the people who asked for less movement.
Five screens in the app this came from had written it that way. Do not hand-roll an
infinite loop; call these.
./gradlew testAndroidHostTest runs the suite on the JVM.
Covered: tweenScaled's contract (a 0f scale collapses duration and delay to zero,
and the scale is a multiplier rather than a switch), stagger's cap, and Arrivals'
first-seen bookkeeping — which is testable without a UI harness precisely because it
lives outside composition.
Not covered: breathing, sweeping and springMotion. Their guards are early returns
inside @Composable functions and need Compose UI test infrastructure this module does
not carry. They are the three things to check by hand after changing anything here — the
sample's reduce-motion switch is there so that check takes a few seconds rather than a
trip to Settings.
Apache-2.0. Extracted from a Compose Multiplatform app, where these primitives earned their shape.
Reduce-motion-safe animation primitives for Compose Multiplatform. Android and iOS.
Two problems, both small, both easy to get wrong:
LazyColumn disposes a row
that scrolls out of view and composes it again on the way back, taking any
remember { false } entrance flag with it. The animation runs again, and the list
never stops twitching.This library is the answer to both, in about 300 lines.
| Android | iOS |
|---|---|
![]() |
![]() |
Same code both times — sample/shared is one App() composable. The list arrives
staggered, scrolls away and back without replaying, replays when asked to, and then
holds perfectly still once reduce motion is on: the entrance snaps in, breathing()
stops at its end value, and the shimmer becomes a plain bar because sweeping() returns
null. Nothing in the sample checks a reduce-motion flag; it provides the scale once.
Run it yourself: sample/README.md.
| Target | Reduce-motion detected from |
|---|---|
| Android | Animator duration scale — what Accessibility → Remove animations writes to |
iOS (iosArm64, iosSimulatorArm64) |
UIAccessibilityIsReduceMotionEnabled |
| JVM / desktop | Nothing — see below |
Desktop has no portable reduce-motion setting, so prefersReducedMotion() answers
false there. That does not leave you stuck: every primitive here multiplies by
LocalMotionScale, so provide the scale yourself and the whole library obeys it.
rememberMotionScale() is a convenience for the two platforms that can answer for
themselves, not the only way in.
No iosX64: Compose Multiplatform 1.11.x publishes no artifacts for the Intel-Mac
simulator, so the dependency could not resolve there anyway.
Published to Maven Central as com.sinebloc:quiet-motion.
// build.gradle.kts
kotlin {
sourceSets {
commonMain.dependencies {
implementation("com.sinebloc:quiet-motion:0.1.0")
}
}
}Version catalog, if you keep one:
# gradle/libs.versions.toml
[libraries]
quiet-motion = { module = "com.sinebloc:quiet-motion", version = "0.1.0" }Compose pulls transitive androidx artifacts, so your repositories need google()
alongside mavenCentral() — as any Compose Multiplatform consumer does:
// settings.gradle.kts
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
}
}Building against an unreleased change is ./gradlew publishToMavenLocal here, plus
mavenLocal() in the consuming build. Cutting a release is RELEASING.md.
Everything here multiplies by one composition local. Provide it near the root of your tree and you are done — there is no second thing to remember:
@Composable
fun AppTheme(content: @Composable () -> Unit) {
CompositionLocalProvider(LocalMotionScale provides rememberMotionScale()) {
content()
}
}rememberMotionScale() returns 0f when the platform asks for reduced motion and 1f
otherwise. On iOS that is UIAccessibilityIsReduceMotionEnabled; on Android it is the
animator duration scale, which is what Accessibility → Remove animations writes to
and the one users actually reach for.
A scale rather than a boolean, deliberately: tweenScaled multiplies, so a future
half-speed preference is a different number here and no second code path anywhere else.
Hold the record of what has already been seen above the list that draws it:
val arrivals = rememberArrivals()
LazyColumn {
itemsIndexed(rows) { index, row ->
Card(Modifier.arriving(arrivals, row.id, delayMillis = QuietMotion.stagger(index)))
}
}A row's first appearance fades and lifts into place. Its second appearance — after
scrolling away and back — draws in place with no animation and holds no animation state
at all. rememberArrivals(selectedTab) makes a tab change count as arriving again,
while scrolling still does not.
arrivalProgress(...) gives you the same 0→1 value when it has to drive layout rather
than opacity. It costs a recomposition per frame, because the value is read during
composition; reach for Modifier.arriving unless something has to change size.
tweenMotion(...) |
The standard tween, already scaled. |
tweenScaled(scale, ...) |
Same, for callers that cannot read a composition local — a NavHost's transition slots and an AnimatedContent's transitionSpec are not composable lambdas. Read LocalMotionScale in your own body and hand the value down. |
springMotion(...) |
Critically damped by default. A plain snap() under reduce-motion. |
scaledMillis(ms) |
A duration, scaled, for when you need the number itself. |
breathing(from, to, ...) |
A value oscillating for something that waits. Returns to and starts no animation under reduce-motion. |
sweeping(...) |
A 0→1 loop for a shimmer. null under reduce-motion — a sweep frozen halfway is a smudge, not a calmer sweep, so the caller draws nothing. |
breathing and sweeping exist because of a bug worth naming. The obvious way to make
an infinite transition respect the scale is to multiply its duration and clamp it to at
least a millisecond. When the scale is zero that yields a one-millisecond loop — a
strobe at frame rate, delivered to precisely the people who asked for less movement.
Five screens in the app this came from had written it that way. Do not hand-roll an
infinite loop; call these.
./gradlew testAndroidHostTest runs the suite on the JVM.
Covered: tweenScaled's contract (a 0f scale collapses duration and delay to zero,
and the scale is a multiplier rather than a switch), stagger's cap, and Arrivals'
first-seen bookkeeping — which is testable without a UI harness precisely because it
lives outside composition.
Not covered: breathing, sweeping and springMotion. Their guards are early returns
inside @Composable functions and need Compose UI test infrastructure this module does
not carry. They are the three things to check by hand after changing anything here — the
sample's reduce-motion switch is there so that check takes a few seconds rather than a
trip to Settings.
Apache-2.0. Extracted from a Compose Multiplatform app, where these primitives earned their shape.