
If you've ever wanted to show a small notification dot or an unread message count on top of an icon in your Android app, Jetpack Compose Badge is the composable you need. Introduced as part of Material 3 for Compose, the Jetpack Compose Badge component gives you a clean, accessible, and fully customizable way to overlay notification indicators on any UI element. Whether you're building a messaging app, an e-commerce cart, or a bottom navigation bar with unread counts, understanding how Badge and BadgedBox work together saves you from writing custom overlay drawing logic from scratch.
The Jetpack Compose Badge system is built from two composables that work together: BadgedBox and Badge.
BadgedBox is a layout composable that acts as the container. It positions whatever badge you give it on top of its content, typically anchored to the upper-right corner. Think of it like a relatively-positioned wrapper — the badge floats over the child content without pushing anything around or changing the layout flow.
Badge is the small visual indicator itself — the red circle, the number pill, or the dot. You pass it into BadgedBox as the badge parameter. On its own, Badge renders as a small filled circle using the error color from your Material 3 theme (typically red). When you place a composable like Text inside Badge, the circle expands into a rounded pill shape that fits the content.
You can read the full API reference for both composables in the official Material 3 documentation.
Before using Jetpack Compose Badge in your Android project, make sure you have the Material 3 library added to your module-level build.gradle file along with the icons library:
dependencies {
implementation("androidx.compose.material3:material3:1.3.1")
implementation("androidx.compose.material:material-icons-extended:1.7.0")
implementation("androidx.compose.ui:ui:1.7.0")
implementation("androidx.compose.ui:ui-tooling-preview:1.7.0")
implementation("androidx.activity:activity-compose:1.9.0")
}
Sync your project after adding these, and you will have access to Badge, BadgedBox, BadgeDefaults, icons, and all the Material 3 navigation components used throughout this guide.
The simplest use of Jetpack Compose Badge is rendering a plain dot indicator on top of an icon — no number, just a small colored circle that signals something new is available.
Here is how that looks in code:
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Email
import androidx.compose.material3.Badge
import androidx.compose.material3.BadgedBox
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview
@Composable
fun SimpleNotificationDot() {
BadgedBox(
badge = {
Badge()
}
) {
Icon(
imageVector = Icons.Filled.Email,
contentDescription = "Email icon with notification"
)
}
}
@Preview(showBackground = true)
@Composable
fun PreviewSimpleNotificationDot() {
SimpleNotificationDot()
}
The BadgedBox takes a badge parameter where you pass in a Badge composable. Because there is no content inside Badge here, it renders purely as a small filled circle. The Icon composable goes inside the content lambda of BadgedBox, and the badge automatically anchors itself to the upper-right corner of that icon.
When you run this preview in Android Studio, you will see a small red dot floating above the email icon. No manual positioning, no Canvas drawing, no custom layout. The BadgedBox handles all of that automatically based on the bounds of its child content.
Most real-world apps need to show not just a presence dot but an actual number — like "5 unread messages" or "12 items in cart." Badge supports this by accepting composable content inside it, which it renders and sizes around automatically.
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ShoppingCart
import androidx.compose.material3.Badge
import androidx.compose.material3.BadgedBox
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview
@Composable
fun CartBadge(itemCount: Int) {
BadgedBox(
badge = {
Badge {
Text(text = itemCount.toString())
}
}
) {
Icon(
imageVector = Icons.Filled.ShoppingCart,
contentDescription = "Shopping cart with $itemCount items"
)
}
}
@Preview(showBackground = true)
@Composable
fun PreviewCartBadge() {
CartBadge(itemCount = 7)
}
When you pass Text inside Badge, the component automatically transitions from a small circle into a rounded pill shape wide enough to display the number. With itemCount set to 7, the badge renders a small pill displaying "7" in white text over a red background. For two-digit numbers, the pill stretches a little wider. All of this shape behavior is managed internally by Material 3 — you just provide the content.
The badge stays anchored to the upper-right corner of the shopping cart icon regardless of the number shown. You do not need to adjust any offsets or padding as the number grows.
By default, Jetpack Compose Badge uses the error container color from your Material 3 theme, which is typically red. You can override this completely using the containerColor and contentColor parameters on Badge, giving you full control over the badge's visual appearance.
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material3.Badge
import androidx.compose.material3.BadgedBox
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.sp
@Composable
fun StyledBadge() {
BadgedBox(
badge = {
Badge(
containerColor = Color(0xFF1565C0),
contentColor = Color.White
) {
Text(text = "NEW", fontSize = 8.sp)
}
}
) {
Icon(
imageVector = Icons.Filled.Notifications,
contentDescription = "Notifications with a new badge"
)
}
}
@Preview(showBackground = true)
@Composable
fun PreviewStyledBadge() {
StyledBadge()
}
Here the badge uses a deep blue background with white text instead of the default red. The containerColor controls the fill of the badge pill, and contentColor controls the color of everything rendered inside it — in this case the "NEW" text label.
You can pass any Color value here, including colors derived from your MaterialTheme, which is a better approach for production apps. Using MaterialTheme.colorScheme.primary as the containerColor lets your badge automatically adapt to light and dark themes without you needing to hardcode hex values for each mode.
One of the most practical uses of Jetpack Compose Badge is inside a NavigationBar, the Material 3 replacement for the older BottomNavigation component. Each tab icon can carry its own independent badge showing activity in that section of the app.
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Chat
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Person
import androidx.compose.material3.Badge
import androidx.compose.material3.BadgedBox
import androidx.compose.material3.Icon
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.tooling.preview.Preview
data class NavTab(val label: String, val badgeCount: Int)
@Composable
fun BottomNavWithBadges() {
val tabs = listOf(
NavTab("Home", badgeCount = 0),
NavTab("Chat", badgeCount = 14),
NavTab("Profile", badgeCount = 2)
)
val icons = listOf(Icons.Filled.Home, Icons.Filled.Chat, Icons.Filled.Person)
var selectedIndex by remember { mutableStateOf(0) }
NavigationBar {
tabs.forEachIndexed { index, tab ->
NavigationBarItem(
selected = selectedIndex == index,
onClick = { selectedIndex = index },
icon = {
BadgedBox(
badge = {
if (tab.badgeCount > 0) {
Badge {
Text(text = tab.badgeCount.toString())
}
}
}
) {
Icon(
imageVector = icons[index],
contentDescription = tab.label
)
}
},
label = { Text(text = tab.label) }
)
}
}
}
@Preview(showBackground = true)
@Composable
fun PreviewBottomNavWithBadges() {
BottomNavWithBadges()
}
This renders a bottom navigation bar where the Chat tab shows a "14" badge and the Profile tab shows "2". When badgeCount is zero — as on the Home tab — no badge appears at all because the if block produces nothing in that case.
Each NavigationBarItem wraps its icon in its own BadgedBox, which means badge state is completely independent per tab. This is the idiomatic Material 3 Compose pattern for bottom navigation badges, and it composes cleanly with the selection state you already manage for switching tabs.
In a real app, badges need to appear and disappear based on live application state — messages arriving, notifications being read, items being added or removed. This is where Compose's reactive state system integrates naturally with the Jetpack Compose Badge component.
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Mail
import androidx.compose.material3.Badge
import androidx.compose.material3.BadgedBox
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@Composable
fun DynamicBadgeControl() {
var messageCount by remember { mutableStateOf(0) }
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
BadgedBox(
badge = {
if (messageCount > 0) {
Badge {
Text(text = messageCount.toString())
}
}
}
) {
Icon(
imageVector = Icons.Filled.Mail,
contentDescription = "Mail icon with $messageCount messages"
)
}
Spacer(modifier = Modifier.height(24.dp))
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
Button(onClick = { messageCount++ }) {
Text("Add Message")
}
Button(onClick = { if (messageCount > 0) messageCount-- }) {
Text("Mark as Read")
}
}
}
}
@Preview(showBackground = true)
@Composable
fun PreviewDynamicBadgeControl() {
DynamicBadgeControl()
}
The messageCount variable starts at zero, so initially no badge is rendered at all. When you tap "Add Message", the count increments and the badge appears over the mail icon showing the current count. When you tap "Mark as Read", the count decrements. The moment it hits zero, the if block produces no Badge composable, and the indicator disappears cleanly.
No manual view invalidation, no notifyDataSetChanged, no show/hide logic. You change the state, Compose recomposes, and the badge either renders or it does not. That reactive loop is the core of how dynamic badge control works in Compose.
This self-contained example brings together all the key concepts — a plain dot badge, a custom-colored styled badge, a dynamic counter with button controls, and a bottom navigation bar where tapping a tab clears its badge — all wired through simple Compose state variables:
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Chat
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Mail
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.Person
import androidx.compose.material3.Badge
import androidx.compose.material3.BadgedBox
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@Composable
fun BadgeShowcaseScreen() {
var selectedTab by remember { mutableStateOf(0) }
var notifCount by remember { mutableStateOf(3) }
var chatCount by remember { mutableStateOf(8) }
val tabLabels = listOf("Home", "Chat", "Profile")
val tabIcons = listOf(Icons.Filled.Home, Icons.Filled.Chat, Icons.Filled.Person)
val tabBadges = listOf(0, chatCount, 0)
Scaffold(
bottomBar = {
NavigationBar {
tabIcons.forEachIndexed { index, icon ->
NavigationBarItem(
selected = selectedTab == index,
onClick = {
selectedTab = index
if (index == 1) chatCount = 0
},
icon = {
BadgedBox(
badge = {
if (tabBadges[index] > 0) {
Badge {
Text(text = tabBadges[index].toString())
}
}
}
) {
Icon(
imageVector = icon,
contentDescription = tabLabels[index]
)
}
},
label = { Text(tabLabels[index]) }
)
}
}
}
) { innerPadding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(24.dp)
) {
Text(
text = "Jetpack Compose Badge Demo",
style = MaterialTheme.typography.headlineSmall
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically
) {
// Plain dot badge
BadgedBox(badge = { Badge() }) {
Icon(
imageVector = Icons.Filled.Mail,
contentDescription = "Mail with notification dot"
)
}
// Custom green styled badge with text
BadgedBox(
badge = {
Badge(
containerColor = Color(0xFF2E7D32),
contentColor = Color.White
) {
Text(text = "NEW", fontSize = 8.sp)
}
}
) {
Icon(
imageVector = Icons.Filled.Notifications,
contentDescription = "Notifications with new badge"
)
}
}
Spacer(modifier = Modifier.height(4.dp))
// Dynamic counted badge
BadgedBox(
badge = {
if (notifCount > 0) {
Badge {
Text(text = notifCount.toString())
}
}
}
) {
Icon(
imageVector = Icons.Filled.Notifications,
contentDescription = "Notification count is $notifCount"
)
}
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Button(onClick = { notifCount++ }) {
Text("Add Notification")
}
Button(onClick = { if (notifCount > 0) notifCount-- }) {
Text("Clear One")
}
}
Text(
text = "Tap the Chat tab below to clear its badge.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
@Preview(showBackground = true)
@Composable
fun PreviewBadgeShowcaseScreen() {
MaterialTheme {
BadgeShowcaseScreen()
}
}