Skip to main content

State-Based Animations

Overview​

State-based animations let you declare what value you want and how it should animate — Litho handles the rest. When the target changes, a new animation starts automatically. If the target changes again mid-flight, the running animation is smoothly retargeted with velocity preservation.

val alpha: Value<Float> = useAnimatedFloat(if (visible) 1f else 0f)

Column(style = Style.alpha(alpha))

That's it — alpha animates between 0f and 1f whenever visible changes.

How it works​

When an animated value updates each frame, only the appropriate phase re-runs. E.g, using the example above, when the alpha animates, layout and resolve are skipped entirely. This is possible because Value<T> integrates with Litho's state read tracking: the framework knows exactly which phases read the value and only re-runs those. As a result, updates are smooth and efficient.

Quick Start​

class FadeExample : KComponent() {
override fun ComponentScope.render(): Component {
val visible = useState { true }
val alpha = useAnimatedFloat(if (visible.value) 1f else 0f)

return Column(
style = Style
.alpha(alpha)
.onClick { visible.update { !it } }
) {
child(Text(text = "Tap to toggle"))
}
}
}

Animation Hooks​

useAnimatedFloat​

Animates a Float value. Starts a new animation whenever targetValue changes.

fun ComponentScope.useAnimatedFloat(
targetValue: Float,
spec: FiniteAnimationSpec<Float> = spring(),
trigger: AnimationTrigger = AnimationTrigger.Immediate,
label: String = "float",
): Value<Float>
val alpha = useAnimatedFloat(if (visible) 1f else 0f)
val scale = useAnimatedFloat(if (expanded) 1.2f else 1f, spec = spring(stiffness = Stiffness.Low))

useAnimatedInt​

Animates an Int value.

fun ComponentScope.useAnimatedInt(
targetValue: Int,
spec: FiniteAnimationSpec<Int> = spring(),
trigger: AnimationTrigger = AnimationTrigger.Immediate,
label: String = "int",
): Value<Int>

useAnimatedColor​

Animates a Color value with per-channel interpolation.

fun ComponentScope.useAnimatedColor(
targetValue: Color,
spec: FiniteAnimationSpec<Color> = spring(),
trigger: AnimationTrigger = AnimationTrigger.Immediate,
label: String = "color",
): Value<Color>

useAnimatedDimen​

Animates a Dimen value. Dimensions are converted to pixels for animation computation.

fun ComponentScope.useAnimatedDimen(
targetValue: Dimen,
spec: FiniteAnimationSpec<Dimen> = spring(),
trigger: AnimationTrigger = AnimationTrigger.Immediate,
label: String = "dimen",
): Value<Dimen>
val height = useAnimatedDimen(
targetValue = if (expanded) 200.dp else 56.dp,
spec = spring(stiffness = Stiffness.Low),
)

Column(style = Style.height(height))

useAnimatedValue​

Generic hook for custom types. Use this when the built-in typed hooks don't cover your value type and you need to provide a custom TypeConverter.

fun <T> ComponentScope.useAnimatedValue(
targetValue: T,
typeConverter: TypeConverter<T>,
spec: FiniteAnimationSpec<T> = spring(),
trigger: AnimationTrigger = AnimationTrigger.Immediate,
label: String = "",
): Value<T>

All typed hooks (useAnimatedFloat, useAnimatedInt, etc.) delegate to this internally.

Retargeting​

If the target value changes while an animation is running, the current animation is interrupted and a new one starts from the current position. For spring animations, the current velocity is captured and passed to the new animation, producing smooth retargeting without visual discontinuities.

No explicit cancellation or restart is needed — just change the state and the animation adapts.

Testing Animations​

Use LithoAnimationTestRule from com.facebook.litho.testing when tests need deterministic control over animation frames and frame timestamps. The rule installs a test frame driver for AnimationController during the test and lets you advance animation time directly instead of waiting for real frames.

@get:Rule
val animationRule = LithoAnimationTestRule { shadowOf(getMainLooper()).idle() }

The optional afterFrame callback is useful in Robolectric tests where Litho or main-looper work should be flushed after each frame.

testView.setRoot(FadeComponent(visible = true))

animationRule.advanceBy(250.ms)
assertThat(testView.lithoView.alpha).isEqualTo(0.25f)
assertThat(animationRule.hasScheduledFrames()).isTrue()

animationRule.advanceBy(750.ms)
assertThat(testView.lithoView.alpha).isEqualTo(1f)
assertThat(animationRule.hasScheduledFrames()).isFalse()

LithoAnimationTestRule works with useAnimatedValue, typed useAnimated* hooks, useAnimatable, and Style.transition {} because they all receive frames through the shared AnimationController.

MethodBehavior
runNextFrame(frameDelay)Dispatches one frame. New duration-based animations start on this frame.
advanceBy(duration)Advances elapsed animation time by duration, bootstrapping scheduled animations.
advanceTo(frameTime)Advances animation time to an absolute test timestamp.
hasScheduledFrames()Returns whether any animations are waiting for another frame.

Deferred start with AnimationTrigger​

By default, animations begin receiving frame callbacks immediately (AnimationTrigger.Immediate). For items in a scrollable collection, this means animations can run to completion while the item is still off-screen. Use useLazyAnimationTrigger() to get a stable lazy trigger and defer animation start until you explicitly trigger it — for example, when the item becomes visible:

val trigger = useLazyAnimationTrigger()
val alpha = useAnimatedFloat(
targetValue = if (visible) 1f else 0f,
trigger = trigger,
)

// Later, when the item becomes visible on screen:
trigger.start()

start() is safe to call multiple times — only the first call has effect.

Animation Specs​

Animation specs describe how an animation behaves. They are lightweight, immutable configuration objects — think of them as recipes. The default spec for all hooks is spring().

Specs form a small hierarchy: spring is a FiniteAnimationSpec (it settles); tween, keyframes, and snap are DurationBasedAnimationSpecs (finite, with a known cycle duration); and infiniteRepeatable produces an InfiniteAnimationSpec that never settles. Target-settling APIs (useAnimated*, useAnimationGroup, Style.transition, AnimatedVisibility, AnimatedContent) require a FiniteAnimationSpec, so an infinite spec can only be used with useInfiniteAnimationGroup.

spring​

Physics-based spring animation. Produces natural, interruptible motion.

fun <T> spring(
stiffness: Stiffness = Stiffness.Medium,
dampingRatio: DampingRatio = DampingRatio.NoBouncy,
): FiniteAnimationSpec<T>

Stiffness presets:

PresetValueFeel
Stiffness.VeryHigh10,000Very fast, stiff
Stiffness.High1,500Fast, snappy
Stiffness.Medium200Balanced (default)
Stiffness.MediumLow120Moderately loose
Stiffness.Low50Loose, slow
Stiffness.VeryLow10Very loose, very slow

Damping ratio presets:

PresetValueFeel
DampingRatio.HighBouncy0.2Significant overshoot and oscillation
DampingRatio.MediumBouncy0.5Moderate overshoot
DampingRatio.LowBouncy0.75Slight overshoot
DampingRatio.NoBouncy1.0No overshoot (default)
// Bouncy expand animation
val scale = useAnimatedFloat(
targetValue = if (expanded) 1f else 0.8f,
spec = spring(stiffness = Stiffness.Low, dampingRatio = DampingRatio.MediumBouncy),
)

tween​

Duration-based animation with an easing curve.

fun <T> tween(
duration: Duration = 300.ms,
easing: Easing = Easing.FastOutSlowIn,
delay: Duration = ZERO,
): DurationBasedAnimationSpec<T>

Easing presets:

PresetDescription
Easing.LinearConstant rate
Easing.EaseInStarts slow, accelerates
Easing.EaseOutStarts fast, decelerates
Easing.EaseInOutSlow start and end
Easing.FastOutSlowInMaterial standard curve (default)
Easing.FastOutLinearInElements leaving screen
Easing.LinearOutSlowInElements entering screen
val alpha = useAnimatedFloat(
targetValue = if (visible) 1f else 0f,
spec = tween(200.ms, easing = Easing.EaseOut),
)

Custom cubic bezier easing:

val customEasing = CubicBezierEasing(0.25f, 0.1f, 0.25f, 1f)
val alpha = useAnimatedFloat(0f, spec = tween(300.ms, easing = customEasing))

keyframes​

Piecewise animation with per-segment easing. Useful for multi-step animations within a single value.

fun <T> keyframes(
duration: Duration,
block: KeyframesBuilder<T>.() -> Unit,
): DurationBasedAnimationSpec<T>
val scale = useAnimatedFloat(
targetValue = if (pressed) 0.95f else 1f,
spec = keyframes(300.ms) {
1f at 0.ms
0.9f at 100.ms using Easing.EaseIn
0.95f at 300.ms using Easing.EaseOut
},
)

snap​

Immediately jumps to the target value with no animation. Useful for testing or when you want an instant state change.

fun <T> snap(): DurationBasedAnimationSpec<T>

repeatable / infiniteRepeatable​

Repeat a duration-based inner spec. repeatable returns a FiniteAnimationSpec (it settles after its iterations); infiniteRepeatable returns an InfiniteAnimationSpec that never settles and can only be consumed by useInfiniteAnimationGroup.

fun <T> repeatable(
iterations: Int,
animation: DurationBasedAnimationSpec<T>,
repeatMode: RepeatMode = RepeatMode.Restart,
initialStartOffset: StartOffset = StartOffset(0),
): FiniteAnimationSpec<T>

fun <T> infiniteRepeatable(
animation: DurationBasedAnimationSpec<T>,
repeatMode: RepeatMode = RepeatMode.Restart,
initialStartOffset: StartOffset = StartOffset(0),
): InfiniteAnimationSpec<T>

The inner animation must be duration-based (tween, keyframes, snap) — a cycle can only repeat if its length is known up front, so springs are excluded. RepeatMode.Restart resets to the start each iteration; RepeatMode.Reverse alternates direction. infiniteRepeatable rejects a zero-duration cycle (e.g. snap()). See StartOffset for initialStartOffset.

Infinite Animations​

Use useInfiniteAnimationGroup for continuous loops that run while a component is mounted, such as pulse indicators, spinners, shimmer sweeps, breathing effects, and typing dots. Infinite animations are separate from target-settling APIs because they never complete: they do not drive onSettled, enter/exit unmount, or Style.transition convergence.

fun ComponentScope.useInfiniteAnimationGroup(
running: Boolean = true,
label: String = "infinite",
): InfiniteAnimation

The returned InfiniteAnimation declares child loops; each loops between an explicit from and to. animateInt, animateColor, animateDimen, and a generic animateValue(..., typeConverter) mirror animateFloat:

fun animateFloat(
from: Float,
to: Float,
animation: DurationBasedAnimationSpec<Float> = tween(),
repeatMode: RepeatMode = RepeatMode.Reverse,
initialStartOffset: StartOffset = StartOffset(0),
label: String = "float",
): Value<Float>
// Pulse: alpha + scale breathe together.
val infinite = useInfiniteAnimationGroup()
val alpha = infinite.animateFloat(from = 0.4f, to = 1f, animation = tween(700.ms))
val scale = infinite.animateFloat(from = 0.85f, to = 1.1f, animation = tween(700.ms))
Dot(style = Style.alpha(alpha).scale(scale))
// Spinner: constant-rate rotation. Restart (never reverse) + Linear.
val rotation = infinite.animateFloat(
from = 0f, to = 360f,
animation = tween(900.ms, easing = Easing.Linear),
repeatMode = RepeatMode.Restart,
)
Icon(style = Style.rotation(rotation))
// Staggered "typing" dots: a FastForward phase offset staggers otherwise-identical loops.
val dots = (0..2).map { i ->
infinite.animateFloat(
from = 0f, to = -14f, animation = tween(360.ms),
initialStartOffset = StartOffset(i * 120, StartOffsetType.FastForward),
)
}
Row { dots.forEach { y -> Dot(style = Style.translationY(y)) } }

StartOffset​

A StartOffset is a one-time shift of a loop's first cycle — distinct from a tween's delay, which is baked into the spec and so repeats every cycle:

  • StartOffset(ms, StartOffsetType.Delay) — hold at from for ms, then begin the first cycle.
  • StartOffset(ms, StartOffsetType.FastForward) — begin immediately, already ms into the cycle. Because the loop is periodic this is a persistent phase shift — the natural way to stagger several otherwise-identical loops.

Pausing with running​

running = false unregisters the loops and holds their current values; flipping back to true restarts each loop from its from (a stop/hold + restart, not an elapsed-time resume). Wire it to visibility so off-screen content isn't animating:

val infinite = useInfiniteAnimationGroup(running = isVisible)
Performance

A running infinite animation keeps a frame callback scheduled every frame for as long as it is mounted — even a cheap alpha pulse, and even when scrolled off-screen but still mounted. Prefer bind-phase properties (alpha / scale / rotation / translation); a loop that animates layout (width / height) re-lays-out every frame. Gate running on visibility for off-screen content.

When to use which​

APIUse for
useInfiniteAnimationGroupcontinuous loops (shimmer, pulse, spinner, dots)
useAnimated*a single value animating to a new target
useAnimationGroupseveral values retargeting together on a state change
AnimatedVisibility / AnimatedContententer/exit and content swaps with retention
Style.transitiondiff-based mount-space changes (move / resize)

Polished, runnable demos (pulse, spinner, shimmer, staggered dots, with a run/pause toggle) live in sample-kotlin/src/main/java/com/facebook/samples/litho/kotlin/animations/statebased/InfiniteAnimationDemos.kt.

Style Properties​

The following Style extensions accept Value<T> for animated binding.

Layout time styles​

Package: com.facebook.litho.animation

StyleType
Style.height(height)Value<Dimen>
Style.width(width)Value<Dimen>

Bind time styles​

Package: com.facebook.litho.animation

StyleTypeDefault (on unbind)
Style.alpha(alpha)Value<Float>1f
Style.rotation(rotation)Value<Float>0f
Style.rotationX(rotationX)Value<Float>0f
Style.rotationY(rotationY)Value<Float>0f
Style.scale(scale)Value<Float>1f
Style.elevation(elevation)Value<Float>0f
Style.translationX(translationX)Value<Float>0f
Style.translationY(translationY)Value<Float>0f
Style.backgroundColor(backgroundColor)Value<Color>Color.fromInt(android.graphics.Color.TRANSPARENT)
note

These APIs are currently annotated with @ExperimentalLithoApi.

Coordinated Animations with useAnimationGroup​

When multiple properties should animate together in response to a shared state change, use useAnimationGroup instead of individual hooks. It provides:

  • Directional spec selection — choose different animation specs based on the direction of a state change (e.g., bouncy spring for expand, quick tween for collapse)
  • Coordinated completion — currentState only updates when ALL animations finish
  • Unified retargeting — all animations retarget together when state changes mid-flight
fun <S> ComponentScope.useAnimationGroup(
targetState: S,
trigger: AnimationTrigger = AnimationTrigger.Immediate,
label: String = "transition",
): AnimationGroup<S>

Adding animated properties​

Call animateFloat, animateInt, animateColor, or animateDimen on the returned group to add animations:

val group = useAnimationGroup(expanded)

val height = group.animateDimen { state -> if (state) 200.dp else 56.dp }
val alpha = group.animateFloat { state -> if (state) 1f else 0f }

Each child maps the target state to a target value via the targetValueByState lambda.

Directional specs with Segment​

The transitionSpec parameter receives a Segment<S> — it knows both the initial state and target state, so you can choose different animations for different directions:

val height = group.animateDimen(
transitionSpec = {
when {
false.isTransitioningTo(true) ->
spring(stiffness = Stiffness.Low) // expand: bouncy spring
true.isTransitioningTo(false) ->
tween(200.ms, easing = Easing.FastOutSlowIn) // collapse: quick tween
else -> spring()
}
},
) { state -> if (state) 200.dp else 56.dp }

This works with any state type — booleans, enums, sealed classes:

enum class Screen { Feed, Profile, Settings }

val group = useAnimationGroup(currentScreen)

val offsetX = group.animateFloat(
transitionSpec = {
when {
Feed.isTransitioningTo(Profile) -> tween(300.ms, easing = Easing.EaseInOut)
Profile.isTransitioningTo(Feed) -> tween(300.ms, easing = Easing.EaseInOut)
else -> tween(200.ms)
}
},
) { screen ->
when (screen) {
Feed -> 0f
Profile -> -1f
Settings -> -2f
}
}

currentState vs targetState​

PropertyUpdates whenUse
targetStateImmediately when useAnimationGroup receives a new valueWhere animations are heading
currentStateOnly when ALL child animations finishLast settled state

currentState lagging behind targetState is intentional — it enables future features like exit animations where a component needs to stay mounted while animating out.

Complete example​

class ExpandableCard(
private val title: String,
private val content: String,
) : KComponent() {
override fun ComponentScope.render(): Component {
val expanded = useState { false }
val group = useAnimationGroup(expanded.value, label = "expand")

val height = group.animateDimen(
label = "height",
transitionSpec = {
when {
false.isTransitioningTo(true) ->
spring(stiffness = Stiffness.Low, dampingRatio = DampingRatio.LowBouncy)
else ->
tween(250.ms, easing = Easing.FastOutSlowIn)
}
},
) { state -> if (state) 200.dp else 56.dp }

val contentAlpha = group.animateFloat(
label = "contentAlpha",
transitionSpec = {
when {
false.isTransitioningTo(true) -> tween(200.ms, easing = Easing.EaseIn)
else -> tween(150.ms, easing = Easing.EaseOut)
}
},
) { state -> if (state) 1f else 0f }

val chevronRotation = group.animateFloat(
label = "chevronRotation",
) { state -> if (state) 180f else 0f }

return Column(
style = Style
.height(height)
.onClick { expanded.update { !it } }
) {
child(
Row {
child(Text(text = title))
child(
Image(
drawable = chevronDrawable,
style = Style.rotation(chevronRotation),
)
)
}
)
child(
Text(
text = content,
style = Style.alpha(contentAlpha),
)
)
}
}
}

Interruption and Retargeting​

The animation system handles interruption automatically. When a target changes mid-flight:

  1. The current animation's velocity is captured
  2. A new animation starts from the current position toward the new target
  3. The captured velocity is passed to the new animation

For springs, this produces physically correct retargeting — the animation continues with its current momentum. For tweens, the velocity is ignored and the new tween starts fresh from the current position.

No explicit cancel(), stop(), or restart() calls are needed. Simply update the state and the animation system handles the transition smoothly.