Skip to content

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 a List row
  • How to use .badge(String) to show a text badge like "NEW" on a TabView tab item
  • Where .badge() is placed in the view hierarchy relative to .tabItem and List rows
  • 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

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

  1. Text("Hello, world!").badge(5) — The .badge() modifier applied directly to the List row's root view. The integer 5 produces a trailing badge showing "5". In a real app this would be .badge(unreadCount) where unreadCount is a computed property or @Published value that returns zero when nothing is pending (a zero value hides the badge automatically).

  2. The remaining three Text rows 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.

  3. Commented-out TabView with .badge("NEW") — Shows the TabView usage pattern. Notice that .badge() is placed on the tab's root view (Color.red) — not inside the .tabItem closure. This is a critical distinction: modifiers inside .tabItem style the tab bar icon/label; .badge() outside .tabItem (but still on the same view) adds the bubble.

  4. 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.

  5. 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. If unreadCount drops to zero, the badge disappears automatically.

  6. Badge on TabView in preview — To test the TabView version, comment out the List block, uncomment the TabView block, and run on the simulator. Xcode's static canvas preview doesn't always render TabView tab 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 on List rows (trailing badge) and TabView tabs (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 .tabItem closure — placement outside .tabItem is 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

Released under the MIT License.