What You Will Build
An expandable Floating Action Button (FAB) that reveals multiple action buttons when tapped. The main FAB rotates its icon while child buttons scale in with staggered delays. This is the speed-dial pattern used in Gmail, Google Maps, and many productivity apps for quick multi-action access.
Why This Pattern Matters
The expandable FAB keeps the primary action prominent while hiding secondary options until needed. It reduces UI clutter without sacrificing functionality. Compose makes this particularly elegant because animateFloatAsState handles all the transition math automatically.
Key Compose Concepts
- animateFloatAsState with spring physics for natural motion
- Staggered tween delays per child button index
- Modifier.rotate() to spin the plus icon into an X
Step 1: Define the Action Data Class
data class FABAction(
val icon: ImageVector,
val label: String,
val onClick: () -> Unit
)
Step 2: Build the Expandable FAB
@Composable
fun FABMenu(actions: List<FABAction>, modifier: Modifier = Modifier) {
var showButtons by remember { mutableStateOf(false) }
val rotation by animateFloatAsState(
targetValue = if (showButtons) -45f else 0f,
animationSpec = spring(dampingRatio = 0.6f),
label = "rotation"
)
Column(
modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Bottom
) {
actions.forEachIndexed { index, action ->
val scale by animateFloatAsState(
targetValue = if (showButtons) 1f else 0f,
animationSpec = tween(
durationMillis = 200,
delayMillis = index * 100
),
label = "scale_$index"
)
if (scale > 0.01f) {
Box(
modifier = Modifier
.padding(bottom = 8.dp)
.scale(scale)
.size(50.dp)
.background(Color.Gray.copy(alpha = 0.3f), CircleShape)
.clickable {
action.onClick()
showButtons = false
},
contentAlignment = Alignment.Center
) {
Icon(
imageVector = action.icon,
contentDescription = action.label,
modifier = Modifier.size(24.dp)
)
}
}
}
Spacer(modifier = Modifier.height(8.dp))
Box(
modifier = Modifier
.size(56.dp)
.shadow(8.dp, CircleShape)
.background(Color.White, CircleShape)
.clickable { showButtons = !showButtons },
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Default.Add,
contentDescription = "Menu",
modifier = Modifier
.size(30.dp)
.rotate(rotation),
tint = Color.Black
)
}
}
}
Step 3: Wire It into a Screen
@Composable
fun FABScreen() {
val actions = listOf(
FABAction(Icons.Default.Language, "Globe") {},
FABAction(Icons.Default.Palette, "Palette") {},
FABAction(Icons.Default.Description, "Document") {}
)
Box(modifier = Modifier.fillMaxSize()) {
FABMenu(
actions = actions,
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(30.dp)
)
}
}
Tips and Pitfalls
- Spring damping ratio of 0.6f gives the rotation a slight bounce that feels organic rather than mechanical.
- Check scale > 0.01f before composing child buttons so they are removed from the tree when hidden, saving composition cost.
- Dismiss on outside tap: Wrap the whole screen in a
Boxwith a transparent clickable background that setsshowButtons = false. - Accessibility: Add
contentDescriptionto every icon and considersemantics { role = Role.Button }for the parent FAB.