What You Will Build
A custom bottom tab bar where the selected tab bounces upward with a spring animation, scales up, and shows a colored circular highlight. Each tab has its own accent color and the content area cross-fades between pages. This replaces the standard BottomNavigation with a more interactive, playful navigation experience.
Why Custom Tab Bars Require Animation Knowledge
Custom tab bars combine several animation techniques: animateFloatAsState for scale, animateDpAsState for vertical offset, spring physics for bouncy feel, and AnimatedContent for page transitions. Mastering these together gives you the tools for any micro-interaction in Compose.
Key Compose Concepts
- animateFloatAsState with spring for bouncy scale animations.
- animateDpAsState for smooth vertical offset transitions.
- AnimatedContent for cross-fading between page content.
- MutableInteractionSource with indication = null to remove the default ripple effect.
Step 1: Define Tabs with Colors
val tabs = listOf(
Triple("Home", Icons.Default.Home, Color(0xFF4A90D9)),
Triple("Explore", Icons.Default.Explore, Color(0xFF50C878)),
Triple("Notifications", Icons.Default.Notifications, Color(0xFFFF8C00)),
Triple("Profile", Icons.Default.Person, Color(0xFF9B59B6))
)
var selected by remember { mutableIntStateOf(0) }
Step 2: Animated Tab Item
tabs.forEachIndexed { index, (title, icon, color) ->
val isSelected = selected == index
val scale by animateFloatAsState(
targetValue = if (isSelected) 1.15f else 1f,
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy
),
label = "scale"
)
val offsetY by animateDpAsState(
targetValue = if (isSelected) (-4).dp else 0.dp,
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy
),
label = "offset"
)
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.scale(scale)
.offset(y = offsetY)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null
) { selected = index }
.padding(horizontal = 12.dp, vertical = 6.dp)
) {
if (isSelected) {
Box(
modifier = Modifier
.size(36.dp)
.clip(CircleShape)
.background(color.copy(alpha = 0.2f)),
contentAlignment = Alignment.Center
) {
Icon(icon, title, tint = color,
modifier = Modifier.size(20.dp))
}
} else {
Icon(icon, title, tint = Color.Gray,
modifier = Modifier.size(22.dp))
}
Spacer(Modifier.height(2.dp))
Text(
title, fontSize = 10.sp,
fontWeight = if (isSelected) FontWeight.Bold
else FontWeight.Normal,
color = if (isSelected) color else Color.Gray
)
}
}
Step 3: Animated Content Area
AnimatedContent(
targetState = selected,
transitionSpec = { fadeIn() togetherWith fadeOut() },
label = "content"
) { idx ->
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(tabs[idx].second, null,
modifier = Modifier.size(80.dp),
tint = tabs[idx].third)
Spacer(Modifier.height(16.dp))
Text(tabs[idx].first, fontSize = 28.sp,
fontWeight = FontWeight.Bold)
}
}
Tips and Pitfalls
- Spring.DampingRatioMediumBouncy creates a playful bounce. Use
DampingRatioLowBouncyfor more bounce orDampingRatioNoBouncyfor smooth transitions. - indication = null removes the default Material ripple, which looks wrong on custom animated tab items.
- CircleShape background with 0.2f alpha on selected icons creates a soft highlight without overwhelming the icon.
- Use Scaffold bottomBar to position the tab bar correctly with safe area insets handling.
Minimum SDK: API 24+ with Compose BOM 2024.01+