3D Card Flip

A card that flips in 3D using graphicsLayer.rotationY with a tuned cameraDistance. Front and back swap content at 90 degrees.

Compose APIs
animateFloatAsStategraphicsLayercameraDistance
Try tweaking
FLIP_DURATION_MSCAMERA_DISTANCEfront and back colors

A 2D rotation around the Y axis collapses every pixel onto a single line as it passes 90 degrees. Without depth information, the card looks like a horizontal shear, not a flip. Compose solves this by letting you set a cameraDistance on the graphics layer, which gives the renderer a focal length to project from. With that focal length in place, rotationY finally looks like a real card turning in space, with edges receding and the surface narrowing into perspective.

In this article, you'll explore how to build a flipping card with animateFloatAsState driving rotationY, why cameraDistance turns a flat shear into a believable 3D rotation, how to swap front and back content at the halfway point, and what each tweakable constant changes about the feel of the animation.

How the example is structured

The example is a single tappable Card inside a Box. State lives in a boolean flipped flag, and the rotation angle is derived from that flag through animateFloatAsState. Tapping the card flips the boolean, which retargets the animation from 0 to 180 degrees (or back).

val FLIP_DURATION_MS = 700
val FLIP_EASING = FastOutSlowInEasing
val CAMERA_DISTANCE_FACTOR = 12f

val FRONT_COLOR = Color(0xFFE91E63)
val BACK_COLOR = Color(0xFFFF7043)

These constants live at the top of the composable. FLIP_DURATION_MS controls how long the rotation takes, FLIP_EASING shapes the speed curve, and CAMERA_DISTANCE_FACTOR controls perspective strength. The two colors give the front and back sides distinct identities so you can tell which face is currently visible.

The interaction layer is small. A remember { mutableStateOf(false) } holds the flipped state, and a Modifier.clickable { flipped = !flipped } toggles it.

var flipped by remember { mutableStateOf(false) }
val rotation by animateFloatAsState(
  targetValue = if (flipped) 180f else 0f,
  animationSpec = tween(durationMillis = FLIP_DURATION_MS, easing = FLIP_EASING),
  label = "flip",
)

animateFloatAsState reads the target value on every recomposition and starts a new tween whenever the target changes. The returned rotation is the current interpolated angle, updated every frame. That single float drives both the visual rotation and the front or back content selection.

graphicsLayer.rotationY for 3D rotation

The actual transform happens inside graphicsLayer. The Card receives a modifier that writes the current rotation angle into the layer, along with the camera distance.

Card(
  modifier = Modifier
    .size(240.dp)
    .graphicsLayer {
      rotationY = rotation
      cameraDistance = CAMERA_DISTANCE_FACTOR * density
    }
    .clickable { flipped = !flipped },
)

rotationY rotates the layer around the vertical axis in the layer's local coordinate system, with the origin at the layer's center by default. At 0 degrees you see the front face squarely. At 90 degrees the layer is edge on, so it has zero visible width. At 180 degrees the layer is flipped, so its content appears mirrored relative to the viewer.

The lambda form of graphicsLayer runs in a GraphicsLayerScope that exposes density. Because the lambda captures rotation from the surrounding composable, it reruns on every frame the animation produces, and the layer's transform updates without recomposing the Card itself. This is why graphicsLayer { ... } is preferred over graphicsLayer(rotationY = rotation) for animated values: the lambda variant reads state during the draw phase, not the composition phase.

Why cameraDistance matters

If you delete the cameraDistance line, the rotation no longer looks like a card turning. It looks like the card is being squashed horizontally and then stretched in the opposite direction. That visual is what a 2D shear of a 3D rotation looks like when projected onto the screen with no perspective.

cameraDistance defines the distance from the virtual camera to the rotating plane, expressed in pixels. The renderer uses that distance as the focal length for a perspective projection. A small value places the camera close to the layer, so points closer to the camera grow noticeably larger than points farther away, producing strong foreshortening. A large value moves the camera far away, flattening the perspective until it approaches an orthographic projection.

The unit is pixels, not dp. That detail matters because the same numeric value produces different visual depth on different screen densities. The example multiplies a unitless factor by density:

val density = LocalDensity.current.density
// ...
cameraDistance = CAMERA_DISTANCE_FACTOR * density

With CAMERA_DISTANCE_FACTOR = 12f, the distance is 12 * density pixels. This is a relaxed setting that gives a gentle perspective. The Compose documentation suggests 8 * density as a typical default, which produces a slightly more dramatic foreshortening. Anything below 4 * density tends to look exaggerated, with edges flaring out. Anything above 30 * density reads as nearly flat.

Multiplying by density keeps the visual depth consistent across phones and tablets. Without it, the same cameraDistance = 12f would look extreme on a high density display and subtle on a low density one.

Swapping content at 90 degrees

The card has two sides. The trick is that a layer with rotationY = 180f would still show the front content mirrored, which is unreadable for text. The example handles this by checking the rotation angle and rendering different content past the halfway point.

colors = CardDefaults.cardColors(
  containerColor = if (rotation <= 90f) FRONT_COLOR else BACK_COLOR,
),

The container color flips at exactly 90 degrees, the moment the layer is edge on and the swap is invisible to the viewer. The content inside the card uses the same threshold:

if (rotation <= 90f) {
  Column(/* front content */) {
    Text("♥", fontSize = 96.sp, color = Color.White)
    Text("Compose", fontSize = 22.sp, color = Color.White)
  }
} else {
  Column(
    modifier = Modifier.graphicsLayer { rotationY = 180f },
    /* back content */
  ) {
    Text("✦", fontSize = 96.sp)
    Text("Saved!", fontSize = 22.sp, color = Color.White)
  }
}

The back content has its own Modifier.graphicsLayer { rotationY = 180f }. This counter rotation cancels out the parent layer's mirroring once the parent passes 90 degrees, so the text on the back face reads left to right instead of appearing reversed. Without that counter rotation, "Saved!" would render as a mirror image.

The threshold check rotation <= 90f runs during composition. Because rotation is a state value, Compose recomposes the Box whenever the angle crosses the boundary, swapping the subtree. The swap is cheap because each branch is small.

Tweaking duration, camera distance, colors

Each constant at the top of the composable changes one specific aspect of the feel.

FLIP_DURATION_MS = 700 is the total time of the rotation in milliseconds. Lower values like 300 produce a snappy flip that feels like a quick reveal. Higher values like 1500 turn it into a slow showcase. Pair the duration with the easing curve. FastOutSlowInEasing accelerates quickly and decelerates into the final angle, which reads as a confident motion. Switching to EaseInOutCubic produces a gentler symmetric curve.

CAMERA_DISTANCE_FACTOR = 12f shapes the perspective. Reducing it to 6f makes the card feel closer to your eye, with stronger edge curvature mid flip. Raising it to 30f flattens the rotation back toward a 2D look. Try sweeping it from 4f to 40f to see the projection change in real time. The value is a multiplier on density, not a literal pixel count, which keeps behavior consistent across devices.

FRONT_COLOR and BACK_COLOR are the two Color values that distinguish the faces. The example uses pink for the front and orange for the back, both fully saturated material tones that swap visibly at 90 degrees. Picking colors with similar lightness keeps the transition feeling balanced. If the back is much darker than the front, the swap can look like a sudden flicker instead of a flip. For a more subdued effect, choose two tints of the same hue and let the icon change carry the identity.

Conclusion

In this article, you've explored how a 3D flip is built from three pieces working together: an animated float driven by animateFloatAsState, a graphicsLayer that applies rotationY and cameraDistance to give the rotation real depth, and a content swap at 90 degrees that keeps the back face readable.

Understanding why cameraDistance is necessary helps you avoid the common mistake of writing rotationY = angle and wondering why the result looks wrong. The renderer needs a focal length to project the rotated plane into screen space, and density scaled multipliers keep that projection consistent across devices. The same reasoning applies to rotationX for vertical flips and to combined transforms when you build more complex card stacks.

Whether you're building a flashcard learning app, a product showcase that reveals details on tap, or a settings toggle with a memorable transition, the pattern stays the same: animate one float, write it into rotationY, set a sensible cameraDistance, and swap content at the halfway point. With those four pieces in place, the rest is tuning duration, easing, and color to match the personality of your screen.

As always, happy coding!

Jaewoong (skydoves)