A beautiful, animated, and completely framework-agnostic bottom navigation bar for Jetpack Compose and Compose Multiplatform
Features β’ Installation β’ Quick Start β’ Customization β’ Examples
- π― 100% Framework Agnostic - Works with any navigation solution (Navigation3, Decompose, Voyager, PreCompose, Appyx) or plain Compose state
- π¨ Beautiful Animations - Smooth transitions, scale effects, and fluid search bar expansion
- π Built-in Search Bar - Expandable search with customizable callbacks
- π Blur Effects - Optional glassmorphism with Haze integration
- π Badge Support - Show notification counts or dot indicators
- π¨ Customizable Indicators - Choose from Ripple, Dot, or Line selection indicators
- β‘ Lightweight - Zero navigation dependencies, minimal overhead
- ποΈ Highly Customizable - Extensive styling options with preset configurations
- π± Production Ready - Battle-tested, performant, and memory-efficient
- π Multiplatform - Android, iOS (iosArm64, iosX64, iosSimulatorArm64)
Add to your libs.versions.toml:
[versions]
flowtab-cmp = "0.5.6-beta"Then in your module's build.gradle.kts:
dependencies {
implementation(libs.flowtab.cmp)
}dependencies {
implementation 'io.github.alims-repo:flowtab-cmp:0.5.6-beta'
}Here's a minimal example to get you started:
@Composable
fun MyApp() {
var selectedScreen by remember { mutableStateOf("home") }
val navItems = remember {
listOf(
NavItem(
id = "home",
label = "Home",
icon = Icons.Outlined.Home,
selectedIcon = Icons.Filled.Home
),
NavItem(
id = "search",
label = "Search",
icon = Icons.Default.Search,
type = NavItemType.Search
),
NavItem(
id = "profile",
label = "Profile",
icon = Icons.Outlined.Person,
selectedIcon = Icons.Filled.Person,
badge = BadgeData(count = 3)
)
)
}
Scaffold(
bottomBar = {
BottomNavigation(
items = navItems,
selectedId = selectedScreen,
onItemSelected = { item ->
selectedScreen = item.id
}
)
}
) { padding ->
Box(modifier = Modifier.padding(padding)) {
when (selectedScreen) {
"home" -> HomeScreen()
"search" -> SearchScreen()
"profile" -> ProfileScreen()
}
}
}
}That's it! FlowTab handles the UI and animations while you control the navigation logic.
FlowTab follows a presentation-only architecture. It manages visual state and animations but delegates all navigation decisions to you through simple callbacks:
BottomNavigation(
items = navItems, // Define your navigation structure
selectedId = selectedId, // YOU control which item is selected
onItemSelected = { item -> // YOU handle navigation logic
selectedId = item.id
// Navigate, log analytics, show toasts, etc.
}
)This design makes FlowTab compatible with any navigation solution or state management approach.
@Composable
fun AppWithNavigation() {
val navController = rememberNavController()
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = navBackStackEntry?.destination?.route ?: "home"
Scaffold(
bottomBar = {
BottomNavigation(
items = navItems,
selectedId = currentRoute,
onItemSelected = { item ->
navController.navigate(item.id) {
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
}
)
}
) { padding ->
NavHost(
navController = navController,
startDestination = "home",
modifier = Modifier.padding(padding)
) {
composable("home") { HomeScreen() }
composable("search") { SearchScreen() }
composable("profile") { ProfileScreen() }
}
}
}class RootComponent(
componentContext: ComponentContext
) : ComponentContext by componentContext {
private val navigation = StackNavigation<Config>()
val stack: Value<ChildStack<*, Child>> = childStack(
source = navigation,
initialConfiguration = Config.Home,
handleBackButton = true,
childFactory = ::child
)
fun navigateTo(config: Config) {
navigation.bringToFront(config)
}
sealed class Config {
object Home : Config()
object Search : Config()
object Profile : Config()
}
}
@Composable
fun AppWithDecompose(component: RootComponent) {
val stack by component.stack.subscribeAsState()
val currentConfig = stack.active.configuration
Scaffold(
bottomBar = {
BottomNavigation(
items = navItems,
selectedId = when (currentConfig) {
is RootComponent.Config.Home -> "home"
is RootComponent.Config.Search -> "search"
is RootComponent.Config.Profile -> "profile"
},
onItemSelected = { item ->
when (item.id) {
"home" -> component.navigateTo(RootComponent.Config.Home)
"search" -> component.navigateTo(RootComponent.Config.Search)
"profile" -> component.navigateTo(RootComponent.Config.Profile)
}
}
)
}
) { padding ->
Children(
stack = stack,
modifier = Modifier.padding(padding)
) {
when (val child = it.instance) {
is Child.Home -> HomeScreen()
is Child.Search -> SearchScreen()
is Child.Profile -> ProfileScreen()
}
}
}
}object HomeTab : Tab {
override val options: TabOptions
@Composable get() = TabOptions(index = 0u, title = "Home")
@Composable
override fun Content() { HomeScreen() }
}
@Composable
fun AppWithVoyager() {
val tabs = remember { listOf(HomeTab, SearchTab, ProfileTab) }
TabNavigator(tab = HomeTab) { tabNavigator ->
Scaffold(
bottomBar = {
BottomNavigation(
items = navItems,
selectedId = tabs.indexOf(tabNavigator.current).toString(),
onItemSelected = { item ->
tabNavigator.current = tabs[item.id.toInt()]
}
)
}
) { padding ->
CurrentTab(modifier = Modifier.padding(padding))
}
}
}Customize how selected items are indicated with three different styles:
A full-width background highlight that fills behind the selected item:
BottomNavigation(
items = navItems,
selectedId = selectedId,
onItemSelected = { item -> selectedId = item.id },
config = NavConfig(
navIndicator = NavIndicator.Ripple(
color = MaterialTheme.colorScheme.primaryContainer,
indicatorPadding = 4.dp
)
)
)A small circular indicator below the selected item:
BottomNavigation(
items = navItems,
selectedId = selectedId,
onItemSelected = { item -> selectedId = item.id },
config = NavConfig(
navIndicator = NavIndicator.Dot(
size = 8.dp,
color = MaterialTheme.colorScheme.primary,
indicatorPadding = 4.dp
)
)
)A horizontal line below the selected item:
BottomNavigation(
items = navItems,
selectedId = selectedId,
onItemSelected = { item -> selectedId = item.id },
config = NavConfig(
navIndicator = NavIndicator.Line(
height = 3.dp,
width = 40.dp,
color = MaterialTheme.colorScheme.primary,
indicatorPadding = 4.dp
)
)
)Indicator Comparison:
- Ripple: Best for bold, high-contrast designs. Fills the entire item background.
- Dot: Minimal and modern. Perfect for clean, Instagram-style navigation.
- Line: Material Design 3 style. Subtle yet clear indication.
Add notification counts or dot indicators:
NavItem(
id = "notifications",
label = "Notifications",
icon = Icons.Outlined.Notifications,
selectedIcon = Icons.Filled.Notifications,
badge = BadgeData(count = 5) // Shows "5"
)
NavItem(
id = "messages",
label = "Messages",
icon = Icons.Outlined.Message,
badge = BadgeData(showDot = true) // Shows a dot
)Create an expandable search experience:
val navItems = listOf(
NavItem(id = "home", label = "Home", icon = Icons.Default.Home),
NavItem(
id = "search",
label = "Search",
icon = Icons.Default.Search,
type = NavItemType.Search // Makes it expandable
),
NavItem(id = "profile", label = "Profile", icon = Icons.Default.Person)
)
BottomNavigation(
items = navItems,
selectedId = selectedId,
onItemSelected = { item -> selectedId = item.id },
onQueryChange = { query ->
// Handle real-time search input
searchViewModel.updateQuery(query)
},
onSearch = { query ->
// Handle search submission (when user presses search button)
searchViewModel.performSearch(query)
}
)Add special action buttons that don't participate in navigation selection:
NavItem(
id = "add",
label = "Add",
icon = Icons.Default.Add,
type = NavItemType.Isolated(rotation = 45f) // Rotates icon
)val instagramConfig = NavConfig(
height = 50.dp,
cornerRadius = 0.dp,
showLabels = false,
enableBlur = false,
showBorder = false,
navColor = NavColor(
backgroundColor = Color.Black,
selectedIconColor = Color.White,
unSelectedIconColor = Color.Gray
),
navIndicator = NavIndicator.Dot(
size = 6.dp,
color = Color.White
)
)val pillConfig = NavConfig(
height = 60.dp,
cornerRadius = 60.dp,
maxWidth = 400.dp,
enableBlur = true,
blurIntensity = 0.95f,
showBorder = true,
elevation = 8.dp,
navIndicator = NavIndicator.Ripple(
color = MaterialTheme.colorScheme.primaryContainer
)
)val floatingConfig = NavConfig(
height = 56.dp,
cornerRadius = 28.dp,
showLabels = false,
elevation = 12.dp,
maxWidth = 320.dp,
navIndicator = NavIndicator.Line(
height = 2.dp,
width = 40.dp,
color = MaterialTheme.colorScheme.primary
)
)Create beautiful blur effects over scrollable content:
val hazeState = rememberHazeState()
Scaffold(
bottomBar = {
BottomNavigation(
items = navItems,
selectedId = selectedId,
onItemSelected = { /* ... */ },
hazeState = hazeState, // Pass the haze state
config = NavConfig(
enableBlur = true,
blurIntensity = 0.95f
)
)
}
) { padding ->
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.hazeSource(state = hazeState) // Mark content as blur source
) {
items(100) { index ->
Text("Item $index")
}
}
}val customColors = NavColor(
backgroundColor = Color(0xFF1E1E1E),
borderColor = Color(0xFF3A3A3A),
selectedIconColor = Color(0xFF00D9FF),
unSelectedIconColor = Color(0xFF666666),
selectedTextColor = Color(0xFF00D9FF),
unSelectedTextColor = Color(0xFF999999),
selectedRippleColor = Color(0x3300D9FF)
)
BottomNavigation(
items = navItems,
selectedId = selectedId,
onItemSelected = { /* ... */ },
config = NavConfig(navColor = customColors)
)FlowTab supports three item types:
sealed class NavItemType {
// Regular navigation item
data object Standard : NavItemType()
// Expandable search bar
data object Search : NavItemType()
// Separate circular action button (calls onItemSelected like any other item)
data class Isolated(val rotation: Float = 0f) : NavItemType()
}FlowTab offers three selection indicator styles:
sealed class NavIndicator {
// Full-width background highlight
// color defaults to NavColor.selectedRippleColor when not specified
data class Ripple(
val color: Color = Color.Unspecified,
val indicatorPadding: Dp = 4.dp
) : NavIndicator()
// Small circular indicator below the icon
// color defaults to NavColor.selectedIconColor when not specified
data class Dot(
val size: Dp = 8.dp,
val color: Color = Color.Unspecified,
val indicatorPadding: Dp = 4.dp
) : NavIndicator()
// Horizontal line below the icon (Material Design 3 style)
// color defaults to NavColor.selectedIconColor when not specified
data class Line(
val height: Dp = 2.dp,
val width: Dp = 40.dp,
val color: Color = Color.Unspecified,
val indicatorPadding: Dp = 4.dp
) : NavIndicator()
}| Parameter | Type | Default | Description |
|---|---|---|---|
height |
Dp | 60.dp | Height of the navigation bar |
cornerRadius |
Dp | 60.dp | Corner radius for rounded edges |
maxWidth |
Dp | 460.dp | Maximum width (useful for tablets) |
iconsSize |
Dp | 20.dp | Size of navigation icons |
animationDuration |
Int | 250 | Animation duration in milliseconds |
enableBlur |
Boolean | true | Enable glassmorphism blur effect (requires hazeState) |
blurIntensity |
Float | 0.95f | Background opacity on top of the blur (0fβ1f) |
showLabels |
Boolean | true | Show text labels below icons |
hideLabelsOnSearchExpand |
Boolean | true | Hide labels when search expands |
showBorder |
Boolean | true | Show border around navigation bar |
elevation |
Dp | 0.dp | Shadow elevation |
navColor |
NavColor | NavColor() | Color configuration |
navIndicator |
NavIndicator | NavIndicator.Ripple() | Selection indicator style |
searchPlaceholder |
String | "Search..." | Placeholder text in the search field |
// β
Keep NavItems stable with remember
val navItems = remember {
listOf(
NavItem(id = "home", label = "Home", icon = Icons.Default.Home),
NavItem(id = "search", label = "Search", icon = Icons.Default.Search)
)
}
// β
Use descriptive, unique IDs
NavItem(id = "user_profile", label = "Profile", icon = Icons.Default.Person)
NavItem(id = "settings_screen", label = "Settings", icon = Icons.Default.Settings)
// β
Handle search callbacks appropriately
onQueryChange = { query -> viewModel.updateSearchQuery(query) }
onSearch = { query -> viewModel.performSearch(query) }
// β
Match indicator style to your design language
navIndicator = NavIndicator.Line() // Material Design 3
navIndicator = NavIndicator.Dot() // Minimal/Instagram style
navIndicator = NavIndicator.Ripple() // Bold/high contrast// β Don't recreate items every composition
val navItems = listOf(NavItem(...)) // Missing remember!
// β Don't use generic or duplicate IDs
NavItem(id = "1", ...)
NavItem(id = "screen", ...)
// β Don't ignore the difference between onQueryChange and onSearch
onQueryChange = { query -> performExpensiveSearch(query) } // Too frequent!Example unit test:
@Test
fun `bottom navigation handles item selection correctly`() {
var selectedId = "home"
composeTestRule.setContent {
BottomNavigation(
items = listOf(
NavItem(id = "home", label = "Home", icon = Icons.Default.Home),
NavItem(id = "profile", label = "Profile", icon = Icons.Default.Person)
),
selectedId = selectedId,
onItemSelected = { item -> selectedId = item.id }
)
}
composeTestRule.onNodeWithText("Profile").performClick()
assertEquals("profile", selectedId)
}Contributions are welcome! Please follow these steps:
- Fork the repository
- Create a feature branch (
git checkout -b feature/amazing-feature) - Commit your changes (
git commit -m 'Add amazing feature') - Push to the branch (
git push origin feature/amazing-feature) - Open a Pull Request
Please read our Contributing Guide for more details.
This project is licensed under the Apache License 2.0 - see the LICENSE file for details.
Copyright 2026 Alim
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
- Inspired by modern mobile design patterns and Material Design 3
- Built with Jetpack Compose and Compose Multiplatform
- Blur effects powered by Haze by Chris Banes
- Icons from Material Icons Extended
- π Report a Bug
- π‘ Request a Feature
- π¬ Join Discussions
- π§ Contact: sourav.0.alim@gmail.com
Using FlowTab in your app? We'd love to feature it! Open an issue with the showcase label.

