Pulsing Heart

rememberInfiniteTransition driving scale and alpha at the same time. The result is a soft heartbeat with a bright on, dim off rhythm.

Compose APIs
rememberInfiniteTransitionanimateFloatRepeatMode.Reverse
Try tweaking
PULSE_MIN_SCALEPULSE_MAX_SCALEPULSE_DURATION_MSRepeatMode

Most Compose animations you write are event driven. A boolean flips, a state object updates, and animateFloatAsState carries the value from where it was to where it should be. That model fits buttons, sheets, and toggles, but it does not fit motion that should never stop. A breathing dot, a pulsing favorite icon, or an attention grabbing call to action needs a loop that runs from the moment the composable enters the tree until it leaves. Compose exposes a separate primitive for that case, rememberInfiniteTransition, and it behaves differently enough from the event driven APIs that it deserves its own mental model.

In this article, you'll explore how a pulsing heart is built on top of rememberInfiniteTransition, how two animateFloat calls stay synchronized inside a single transition, why RepeatMode.Reverse produces the breathing in and breathing out shape of a heartbeat, and how each tweakable constant in the example reshapes the motion.

How the example is structured

The composable renders a single heart glyph centered inside a Box, then animates two visual properties of that glyph over time. The shape itself is the simplest possible target, a Text with the character at a large font size:

Text(
  text = "♥",
  fontSize = HEART_FONT_SIZE_SP.sp,
  modifier = Modifier
    .scale(scale)
    .alpha(alpha),
)

HEART_FONT_SIZE_SP is 120, which gives the heart enough surface area for the scale change to read clearly. The two values driving the motion are scale and alpha, both Float values produced by an infinite transition higher in the function. Modifier.scale multiplies the rendered size around the center of the layout, while Modifier.alpha adjusts the opacity of every pixel the Text draws. Applying both at once means each beat both grows and brightens, then shrinks and dims, which reads as a single coordinated pulse rather than two separate effects.

The order of the modifiers matters less here than usual because both scale and alpha operate on the rendered output of the Text rather than its layout. Either order produces the same visual result, since neither modifier reports a different size to the parent.

rememberInfiniteTransition and animateFloat

The transition itself is created once per composition with a single call:

val transition = rememberInfiniteTransition(label = "pulse")

rememberInfiniteTransition returns an InfiniteTransition object that acts as a holder for any number of synchronized infinite animations. It does not animate anything by itself. Instead, you attach values to it through extension functions like animateFloat, and the transition takes responsibility for driving them all from the same internal clock. The label parameter is metadata for the Animation Inspector in Android Studio.

Two animateFloat calls hang off this transition, one for each property:

val scale by key(PULSE_DURATION_MS, PULSE_EASING) {
  transition.animateFloat(
    initialValue = SCALE_MIN,
    targetValue = SCALE_MAX,
    animationSpec = infiniteRepeatable(
      animation = tween(durationMillis = PULSE_DURATION_MS, easing = PULSE_EASING),
      repeatMode = RepeatMode.Reverse,
    ),
    label = "scale",
  )
}

The alpha value is built the same way, with ALPHA_MIN and ALPHA_MAX instead of the scale bounds. Because both animations share the same transition, the same duration, and the same easing, they advance in lockstep. When scale is at its peak, alpha is at its peak. When one is on the way down, the other is too. This is the reason the heart looks like a single breathing object instead of a glyph that scales on one schedule and fades on another.

The key(PULSE_DURATION_MS, PULSE_EASING) wrapper is a small but important detail. InfiniteTransition.animateFloat reads its animationSpec only on first composition. Editing the duration or easing during hot reload would not normally take effect, because Compose only re-checks initialValue and targetValue afterward. Wrapping the call in key forces a fresh TransitionAnimationState whenever the spec changes, so tweaks during development apply immediately.

RepeatMode.Reverse and the heartbeat shape

The shape of the loop is controlled by infiniteRepeatable, which wraps a single tween and tells the transition how to handle the end of each cycle. The choice of repeatMode decides whether each cycle restarts from the beginning or plays in reverse.

RepeatMode.Restart runs the tween from initialValue to targetValue, then snaps back to initialValue and runs forward again. The visual effect is a sawtooth, a smooth ramp followed by an instantaneous jump. For a pulsing heart, that snap would feel jarring, since the heart would suddenly shrink to its smallest size every time it reached its peak.

RepeatMode.Reverse runs the tween forward, then plays the same tween backward, then forward again. The value moves smoothly between initialValue and targetValue in both directions, never jumping. That is the breathing in and breathing out shape that makes a heartbeat feel organic.

Easing inside the tween shapes the curve within each half cycle. The example uses FastOutSlowInEasing, a Material curve that accelerates quickly out of the start and decelerates as it approaches the end. With Reverse, this means the heart leaves its smallest state quickly, slows as it reaches its largest state, holds visually near the peak for a moment, then accelerates again on the way back. Switching to LinearEasing would produce a constant rate of change, which feels mechanical. EaseInOutCubic gives a softer, more rounded pulse with longer pauses at both extremes.

Tweaking pulse range, duration, and mode

Each constant at the top of the function controls a different dimension of the motion.

PULSE_MIN_SCALE and PULSE_MAX_SCALE (named SCALE_MIN and SCALE_MAX in the source, set to 0.85f and 1.15f) define how far the heart shrinks and grows around its natural size. The current range gives a noticeable thirty percent total swing, fifteen percent in each direction. Pulling them closer to 1.0f, say 0.95f and 1.05f, produces a subtle pulse that reads as a gentle breathing rhythm. Pushing them further apart, like 0.7f and 1.3f, makes the heart visibly throb and demands attention. Crossing 1.0f in both directions is what makes the motion feel symmetric. If both values were above one, the heart would only ever appear larger than its base size.

PULSE_DURATION_MS is 600 milliseconds in the example, with a hint that fast values around 200 and slow values around 3000 are both viable. This is the duration of one half cycle, not the full beat. With RepeatMode.Reverse, the full breathing cycle takes 2 * PULSE_DURATION_MS, so 600 produces a complete pulse every 1.2 seconds. That cadence sits close to a calm human heart rate and reads as alive without being anxious. Dropping the duration to 200 produces an urgent flutter useful for warnings or notifications. Pushing it to 3000 produces a slow meditative breathing motion suitable for ambient screens.

The RepeatMode itself is the most disruptive tweak. Switching to RepeatMode.Restart keeps the same minimum, maximum, and duration but introduces the snap back at the end of every cycle. Combined with a short duration, it can create a pumping or strobe effect rather than a heartbeat. For a different effect entirely, replacing tween inside infiniteRepeatable with keyframes would let the value spend more time at certain points, which is how you build the classic two beat lub dub heart pattern instead of a single smooth pulse.

PULSE_EASING interacts with all of the above. With aggressive easing curves, the visual time spent near the extremes increases even though the underlying duration is constant. With linear easing, the value spends equal time at every point in its range, which can make the heart look like it is moving on a metronome rather than breathing.

Conclusion

In this article, you've explored how a pulsing heart is assembled from rememberInfiniteTransition, two synchronized animateFloat calls, an infiniteRepeatable spec built on a single tween, and RepeatMode.Reverse. Each piece does one thing. The transition owns the clock, the animateFloat calls own individual values, the repeatable spec owns the cycle behavior, and Modifier.scale and Modifier.alpha translate those values into pixels.

Understanding these internals helps you reach for the right primitive when you need motion that does not stop. Event driven animation APIs like animateFloatAsState are the wrong tool for ambient loops, because they tie progress to state changes that never come. rememberInfiniteTransition is the right tool because it gives you a stable, shared clock and a place to hang multiple correlated values without writing your own coroutine or withFrameNanos loop.

Whether you are building a favorite button that throbs while a like is in flight, an idle indicator that breathes on a dashboard, or a call to action that gently invites a tap, this pattern of one infinite transition driving several animateFloat values is the foundation. Adjust the bounds, the duration, the repeat mode, and the easing, and the same five lines of structure will carry you from a calm pulse to an urgent flutter.

As always, happy coding!

Jaewoong (skydoves)