How to add Badges to SwiftUI TabView and List in iOS 15 | SwiftUI Bootcamp #59
Badges are the small red circles with numbers or text that appear on tab bar icons and list items to signal unread counts, pending actions, or new content. iOS 15 added native .badge() support to SwiftUI, replacing UIKit's tabBarItem.badgeValue workaround. After this lesson you'll know how to add numeric and text badges to both TabView tab items and List rows, and understand when badges improve the user experience versus when they create noise.
What You'll Learn
- How to use
.badge(Int)to show a numeric count badge on aListrow - How to use
.badge(String)to show a text badge like "NEW" on aTabViewtab item - Where
.badge()is placed in the view hierarchy relative to.tabItemandListrows - Best practices for badge count management to avoid overwhelming users with noise
Mental Model
Think of badges like the "you've got mail" indicator on a physical mailbox flag. The flag (badge) is raised when there's something new that needs attention. For a tab bar, the flag is on the tab's icon — you glance at it without even opening the tab to know something is waiting. For a list item, the flag is on the row itself — a small number next to the item tells you how many unread messages or pending actions belong to it.
The critical thing about flags is that they should only be raised for genuinely actionable information. A mailbox flag raised for a catalog you didn't ask for trains you to ignore the flag. Badges work the same way: overuse teaches users to ignore them, which defeats their entire purpose.
Detailed Explanation
.badge() was introduced in iOS 15 as a first-class SwiftUI modifier. It comes in two flavors: .badge(Int) for numeric counts and .badge(String?) for short text labels. When the value is zero (for Int) or nil (for String?), no badge is shown — this makes it safe to bind directly to a count variable without guarding for zero.
For List rows, the badge renders as a small rounded-rectangle label on the trailing edge of the row. The text or number appears in the system's secondary label color against the row background. This is different from TabView badges, which render as the familiar red bubble above the tab icon.
For TabView tab items, .badge() is applied to the tab's root view (not to the .tabItem closure), and it renders as the classic red notification bubble. You can pass an integer count or a string like "NEW". String badges are useful for state indicators ("NEW", "BETA", "PRO") while integer badges are best for countable actions (unread messages, notifications).
Managing badge values correctly is as important as displaying them. Connect badge values to your actual data state — read counts, notification counts, pending item arrays. When a user views the content, clear the badge immediately. Stale badges that don't update erode user trust. In a real app, badge counts come from your view model or notification service, not from hardcoded values.
Code Structure
BadgesBootcamp.swift demonstrates both usage contexts. The active code shows an integer badge (value 5) on the first row of a List. The commented-out block shows how to add string badges ("NEW") to tabs in a TabView. Both examples use the same .badge() modifier with different container contexts.
Complete Code
BadgesBootcamp.swift
import SwiftUI
// List
// TabView
struct BadgesBootcamp: View {
var body: some View {
List {
Text("Hello, world!")
.badge(5) // shows "5" as a trailing badge on this specific list row
Text("Hello, world!") // no badge — normal row appearance
Text("Hello, world!")
Text("Hello, world!")
}
// TabView {
// Color.red
// .tabItem {
// Image(systemName: "heart.fill")
// Text("Hello")
// }
// .badge("NEW") // string badge renders as a red bubble with "NEW" text above the tab icon
//
// Color.green
// .tabItem {
// Image(systemName: "heart.fill")
// Text("Hello")
// }
// // no badge on this tab
//
// Color.blue
// .tabItem {
// Image(systemName: "heart.fill")
// Text("Hello")
// }
// }
}
}
struct BadgesBootcamp_Previews: PreviewProvider {
static var previews: some View {
BadgesBootcamp()
}
}Code Walkthrough
Text("Hello, world!").badge(5)— The.badge()modifier applied directly to theListrow's root view. The integer5produces a trailing badge showing "5". In a real app this would be.badge(unreadCount)whereunreadCountis a computed property or@Publishedvalue that returns zero when nothing is pending (a zero value hides the badge automatically).The remaining three
Textrows without.badge()— These rows render without a badge, demonstrating that badges are per-item and opt-in. Every row in a list does not need a badge — apply them selectively to rows that have actionable pending content.Commented-out
TabViewwith.badge("NEW")— Shows theTabViewusage pattern. Notice that.badge()is placed on the tab's root view (Color.red) — not inside the.tabItemclosure. This is a critical distinction: modifiers inside.tabItemstyle the tab bar icon/label;.badge()outside.tabItem(but still on the same view) adds the bubble.String badge
"NEW"vs. integer badge — String badges are appropriate for qualitative states ("NEW", "BETA") that aren't countable. Integer badges are for counts (5 unread messages). Apple's Human Interface Guidelines suggest keeping string badge values very short (3-4 characters max) because there is limited space in the bubble.Automatic zero-hiding for integer badges —
.badge(0)shows no badge. This means you can write.badge(viewModel.unreadCount)without checking if the count is zero — SwiftUI handles that gracefully. IfunreadCountdrops to zero, the badge disappears automatically.Badge on
TabViewin preview — To test theTabViewversion, comment out theListblock, uncomment theTabViewblock, and run on the simulator. Xcode's static canvas preview doesn't always renderTabViewtab items correctly — verify badge appearance on a real device or simulator.
Common Mistakes
Mistake: Placing .badge() inside the .tabItem closure instead of outside it.tabItem { Image(...); Text(...) } configures the tab icon and label. .badge() must be placed on the parent view (the tab's content), not inside the .tabItem closure. Placing .badge() inside .tabItem produces a compile error or is silently ignored.
Mistake: Hardcoding badge values instead of binding them to real data
A badge value of 5 that never changes loses all meaning — users quickly learn to ignore it. Badges should always reflect live state. Connect them to @Published properties from your view model, and clear the count when the user interacts with the content (e.g., mark messages as read when the screen appears).
Mistake: Showing badges for non-actionable information, training users to ignore them
Badges are attention-demanding. Using them for promotional content ("You haven't opened this tab in a while!"), status information that doesn't require action ("Last synced 5 minutes ago"), or events the user didn't trigger causes badge fatigue. Reserve badges for content that genuinely requires the user's attention and can be "resolved" by taking action.
Key Takeaways
.badge(Int)and.badge(String?)are one-modifier additions that work onListrows (trailing badge) andTabViewtabs (red bubble over the tab icon) — an integer value of zero or a nil string value hides the badge automatically.- For
TabView, apply.badge()to the tab's root content view, not inside the.tabItemclosure — placement outside.tabItemis the correct position. - Connect badge values to live data that users can act on and clear; hardcoded or stale badges train users to ignore them, undermining the entire purpose of the notification pattern.
Last updated: June 27, 2026