Pendulum Wave

N pendulums with progressively shorter periods. They start in phase, drift apart into apparent chaos, and resync at the period boundary.

Compose APIs
withFrameNanosCanvascos
Try tweaking
PENDULUM_COUNTBASE_PERIOD_SECSYNC_PERIOD_SECbob colors

The pendulum wave is the classroom physics demo every student remembers. A wooden frame holds a row of pendulums, each one slightly shorter than the last. You pull them all back together, release them in unison, and for the first second they swing as one. Then they fan out into traveling waves, then into apparent chaos, then back into a single line as if nothing had ever happened. The math behind it is one cosine per pendulum. The spectacle is entirely about the relative phase between them.

In this article, you'll explore how a single Compose Canvas reproduces that demo with withFrameNanos driving a shared time variable, how cos turns time into an angle, how the period formula guarantees that all pendulums realign at a fixed boundary, and how each tweakable constant changes the character of the wave.

How the example is structured

AnimationExample20 lays out PENDULUM_COUNT pendulums in a horizontal row. A single support bar runs across the top of the canvas, and each pendulum hangs from a pivot point spaced evenly along that bar. There is no per pendulum Animatable, no infiniteRepeatable, no separate state object. Every pendulum is computed inside one for loop on every frame.

val PENDULUM_COUNT = 18
val SYNC_PERIOD_SEC = 30f
val BASE_OSCILLATIONS = 20
val MAX_ANGLE_DEG = 32f

var time by remember { mutableStateOf(0f) }

The single source of truth is time. It is a plain Float measured in seconds. Each pendulum reads time, applies its own period, and produces an angle. Because every pendulum reads the same clock, the whole row stays mathematically coherent. Drift between pendulums is real drift caused by different periods, never an accidental drift caused by independent animation state going out of sync.

Periods that resync at SYNC_PERIOD_SEC

The clever piece of the demo is choosing the period for each pendulum so the whole row realigns on a known schedule. The source uses oscillation counts rather than periods directly:

val oscillations = (BASE_OSCILLATIONS + i).toFloat()
val omega = 2f * PI.toFloat\(\) * oscillations / SYNC_PERIOD_SEC

Pendulum i completes exactly BASE_OSCILLATIONS + i full swings during the SYNC_PERIOD_SEC window. With BASE_OSCILLATIONS = 20 and PENDULUM_COUNT = 18, the leftmost pendulum makes 20 swings in 30 seconds and the rightmost makes 37 swings in the same 30 seconds. Every count is an integer, so at t = SYNC_PERIOD_SEC every pendulum has completed a whole number of cycles and sits back at its starting angle.

You can read this as a period formula too. period_i = SYNC_PERIOD_SEC / (BASE_OSCILLATIONS + i). The leftmost pendulum has the longest period, the rightmost the shortest, and the spread between them grows with PENDULUM_COUNT. Between resync points, the relative phases evolve through traveling waves, two waves, three waves, and finally what looks like noise before snapping back into a line.

Computing position from time

Once you have angular frequency omega, the angle of a pendulum at time t is a single cosine:

val theta = maxAngleRad * cos(omega * time)

val bobX = pivotX + length * sin(theta)
val bobY = pivotY + length * cos(theta)

Why cosine and not sine? Cosine is the solution to the simple harmonic oscillator equation that satisfies the natural release condition: at t = 0, cos(0) = 1, so every pendulum starts at its maximum angle with zero velocity. That matches the physical demo where you pull the pendulums to one side and let go. A pure pendulum is only approximately a simple harmonic oscillator, but at small amplitudes the small angle approximation makes theta(t) = theta_max * cos(omega * t) an accurate model.

MAX_ANGLE_DEG = 32f is converted to radians once with maxAngleRad = MAX_ANGLE_DEG * PI.toFloat\(\) / 180f. The bob position then comes from elementary trigonometry. The pivot is the origin, the string has fixed length, so the bob sits at \(pivotX + length * sin(theta), pivotY + length * cos(theta)). Note that cos(theta) is positive when the pendulum hangs down, so adding it to pivotY correctly places the bob below the pivot.

Drawing strings and bobs in one Canvas pass

The entire visual is one Canvas composable. Inside its DrawScope, a single for loop walks through every pendulum and emits its draw calls in order:

for (i in 0 until PENDULUM_COUNT) {
  val pivotX = horizontalPadding + i * spacing
  val oscillations = (BASE_OSCILLATIONS + i).toFloat()
  val omega = 2f * PI.toFloat\(\) * oscillations / SYNC_PERIOD_SEC
  val theta = maxAngleRad * cos(omega * time)

  val bobX = pivotX + length * sin(theta)
  val bobY = pivotY + length * cos(theta)
  val bob = Offset(bobX, bobY)

After computing the bob position, the loop draws the string and the bob. The string is one drawLine from pivot to bob. The bob is a stack of drawCircle calls layered to give it depth:

drawLine(
  color = STRING_COLOR,
  start = Offset(pivotX, pivotY),
  end = bob,
  strokeWidth = stringWidthPx,
)

drawCircle(
  color = pendulumColor,
  radius = bobRadiusPx,
  center = bob,
)

A drop shadow is drawn first, then the main colored circle, then a smaller highlight circle near the upper left edge to suggest a light source, then a tiny white specular dot. The result is a glassy ball without using gradients or shaders, just three offset circles. Because everything happens inside one draw scope, layering is automatic: later drawCircle calls naturally paint over earlier ones.

The color of each pendulum comes from lerp(COLOR_FIRST, COLOR_LAST, gradientT) where gradientT = i / (PENDULUM_COUNT - 1). With COLOR_FIRST = 0xFFFFD740 (amber) and COLOR_LAST = 0xFFE040FB (magenta), the row sweeps across the warm side of the spectrum from left to right.

Driving t with withFrameNanos

The clock that powers the whole animation is a tight LaunchedEffect loop:

LaunchedEffect(Unit) {
  var lastFrame = 0L
  while (true) {
    androidx.compose.runtime.withFrameNanos { frameTime ->
      if (lastFrame == 0L) lastFrame = frameTime
      val deltaSec = (frameTime - lastFrame) / 1_000_000_000f
      lastFrame = frameTime
      time += deltaSec
      tick++
    }
  }
}

withFrameNanos suspends until the Compose runtime is about to render the next frame, then resumes with the frame timestamp in nanoseconds. The first frame is treated as the zero point by checking if (lastFrame == 0L). Every subsequent frame computes deltaSec, the actual elapsed wall clock time since the previous frame, and adds it to time.

This matters because frames are not perfectly evenly spaced. A frame might take 16 ms one moment and 24 ms the next. By integrating real deltas instead of assuming a fixed 60 Hz, the animation runs at the correct physical speed regardless of frame rate. Drop a few frames and the pendulums still resync at exactly SYNC_PERIOD_SEC of wall clock time. The tick counter is incremented as a Compose state read trigger inside the canvas, ensuring the draw scope reads the new time and recomposes the frame.

Tweaking count, base period, sync period, colors

Every constant at the top of the function shapes the demo in a specific way.

  • PENDULUM_COUNT: Range 5 (sparse) to 30 (dense rainbow wave). More pendulums make the traveling wave smoother and the chaotic middle phase richer. Fewer pendulums make the resync moment more obvious because each ball is easier to track individually.
  • BASE_PERIOD_SEC (expressed here as BASE_OSCILLATIONS = 20): This is the slowest pendulum's swing count over a sync window. Larger values mean every pendulum swings faster, and the spread between adjacent pendulums shrinks because (20 + i) / (20 + i + 1) is closer to one than (5 + i) / (5 + i + 1). Smaller values produce a more dramatic spread but a less wave like motion.
  • SYNC_PERIOD_SEC: Range 10 (frantic) to 60 (slow contemplation). This is the resync interval in seconds. Halving it doubles every pendulum's frequency without changing the visual pattern, so the demo plays out twice as fast. Doubling it lets the eye track each transitional shape.
  • Bob colors COLOR_FIRST and COLOR_LAST: lerp interpolates linearly in RGB. Pick two complementary colors for high contrast across the row, or two near neighbors on the wheel for a subtle gradient. The string color and background stay neutral so the bobs carry the visual weight.

Two more knobs are worth knowing. MAX_ANGLE_DEG sets the swing amplitude. Below 20 degrees the pendulum looks almost like a lazy oscillator, above 40 degrees the swings cross into territory where the small angle approximation starts to lie about real pendulums (though for the demo it still looks right). TRAIL_LENGTH controls how many past bob positions are drawn as faint circles behind the current bob, which makes the wave shape easier to read at a glance.

Conclusion

In this article, you've explored a Compose port of the classic pendulum wave demo. You saw how withFrameNanos integrates real frame deltas into a shared time variable, how cos(omega * time) produces each pendulum's angle, how integer oscillation counts within SYNC_PERIOD_SEC guarantee a perfect resync, and how a single Canvas draw loop renders strings and bobs by stacking primitives.

Understanding these internals helps you see that the demo's complexity is entirely emergent. There is no choreography, no keyframes, no easing curves. There is only one cosine per pendulum, evaluated against the same clock, with periods chosen to be commensurate with the sync window. Every visual transition you observe is a side effect of how the relative phases evolve when their frequencies are integer multiples of a common base.

Whether you're building data visualizations that need synchronized oscillators, designing loading indicators that breathe in coordinated patterns, or just exploring how Canvas and withFrameNanos compose into smooth time based motion, this example shows the entire pattern in under 200 lines. Pull a few constants, watch the wave change character, and you have a working intuition for how shared time plus per element math produces motion that looks far more complicated than the code that creates it.

As always, happy coding!

Jaewoong (skydoves)