Skip to content
Daniel S edited this page Jul 2, 2026 · 1 revision

Badges

DFBadge is DesignFoundation's labeling primitive for surfacing counts, status, categories, and presence signals. A badge is a small, high-contrast chip that attaches meaning to adjacent content without disrupting layout flow.

Related pages: Primitives · Avatars · Lists-Tables-and-Data · Theming


Table of Contents

  1. Overview
  2. Initializer Reference
  3. Style Variants
  4. Color System
  5. The 99+ Cap
  6. Positioning Patterns
  7. Tinted Style Deep-Dive
  8. Dot Badge
  9. Complete Code Examples
  10. Accessibility

Overview

Badges communicate three distinct classes of information:

Class Use Initializer
Count Unread items, pending tasks, queue depth DFBadge(count:)
Label Status, category, feature flag, version DFBadge(text:)
Presence Online/offline, unread dot DFBadge.dot
import DesignFoundation

DFBadge(count: 12)
DFBadge(text: "New")
DFBadge.dot

Initializer Reference

DFBadge(count: Int)

Renders a numeric badge. Automatically applies the 99+ cap when count > 99.

DFBadge(count: 0)     // "0"
DFBadge(count: 7)     // "7"
DFBadge(count: 100)   // "99+"

// Common pattern — hide at zero
if unreadCount > 0 {
    DFBadge(count: unreadCount)
}

DFBadge(text: String)

Renders a short string label. Keep text to 1–3 words.

DFBadge(text: "New")
DFBadge(text: "Pro")
DFBadge(text: "Beta")
DFBadge(text: "Active")
DFBadge(text: "v2.4.1")

DFBadge.dot

A minimal colored circle with no label.

DFBadge.dot                                       // primary color
DFBadge.dot.foregroundStyle(theme.colors.success) // green
DFBadge.dot.foregroundStyle(theme.colors.error)   // red
DFBadge.dot.foregroundStyle(.gray)                // offline

Style Variants

Apply styles with .dfBadgeStyle(_:):

DFBadge(text: "Pro").dfBadgeStyle(.filled)    // default
DFBadge(text: "Pro").dfBadgeStyle(.outlined)
DFBadge(text: "Pro").dfBadgeStyle(.tinted)
DFBadge(text: "Pro").dfBadgeStyle(.glass)     // iOS 26+ / macOS 26+
Style Background Label Color Border Best For
.filled Solid color White None High-priority counts, action-required states
.outlined Transparent color 1pt color Lower-priority labels, version strings
.tinted color at ~15% opacity color None Category tags, filter chips, layered surfaces
.glass System material blur color Subtle Overlays on photos, hero images

Color System

The default color is theme.colors.primary. Override per badge:

@Environment(\.dfTheme) private var theme

DFBadge(text: "Active",    color: theme.colors.success)  // green
DFBadge(text: "Warning",   color: theme.colors.warning)  // amber
DFBadge(text: "Error",     color: theme.colors.error)    // red
DFBadge(text: "Pro",       color: theme.colors.primary)  // brand
Meaning Color Example text
Success / Active theme.colors.success Active, Connected, Verified
Warning / Caution theme.colors.warning Pending, Review, Expiring
Error / Danger theme.colors.error Failed, Overdue, Blocked
Brand / Premium theme.colors.primary Pro, Premium, Plus
Neutral / Inactive .gray Archived, Disabled, Closed

The 99+ Cap

DFBadge(count:) caps display at 99. Any count > 99 renders as "99+". VoiceOver reads this as "99 or more".

DFBadge(count: 99)    // "99"
DFBadge(count: 100)   // "99+"

Positioning Patterns

Badge on a Navigation Bar Icon

DFIcon("bell.fill")
    .overlay(alignment: .topTrailing) {
        if notificationCount > 0 {
            DFBadge(count: notificationCount, color: theme.colors.error)
                .offset(x: 8, y: -8)
        }
    }

Badge in a HStack

HStack(alignment: .firstTextBaseline, spacing: theme.spacing.sm) {
    DFText("Subscription", style: .body)
    DFBadge(text: "Pro", color: theme.colors.primary)
}

Badge on an Avatar

DFAvatar(name: "Jamie Lin")
    .overlay(alignment: .bottomTrailing) {
        DFBadge.dot
            .foregroundStyle(isOnline ? theme.colors.success : .gray)
            .background(Circle().fill(theme.colors.background).padding(-2))
    }

Badge in a DFListRow

DFListRow(
    "Messages",
    subtitle: "3 unread threads",
    icon: "message.fill",
    accessory: .custom(AnyView(
        DFBadge(count: 3, color: theme.colors.error)
    ))
)

Tinted Style Deep-Dive

.tinted applies approximately 15% opacity to the badge color as a fill. Use it when:

  • Multiple simultaneous badges coexist — reduced weight prevents visual overload
  • Layered card surfaces — solid fill feels heavy on theme.colors.surface
  • Category / tag groups — communicates non-urgent categorization
// Tag cloud — .tinted at scale
let skills = ["Swift", "SwiftUI", "iOS", "macOS"]
HStack {
    ForEach(skills, id: \.self) { tag in
        DFBadge(text: tag).dfBadgeStyle(.tinted)
    }
}

// Status column in a table — tinted per row
ForEach(deployments) { deploy in
    HStack {
        DFText(deploy.name, style: .body)
        Spacer()
        DFBadge(text: deploy.status.label, color: deploy.status.color)
            .dfBadgeStyle(.tinted)
    }
}

Dot Badge

DFBadge.dot renders an 8pt circle with no label.

// Online presence indicator
DFAvatar(name: user.name)
    .overlay(alignment: .bottomTrailing) {
        DFBadge.dot
            .foregroundStyle(user.isOnline ? theme.colors.success : Color.gray)
            .background(Circle().fill(theme.colors.background).padding(-2))
    }

// Unread channel marker
DFListRow(channel.name, icon: "number") {
    if channel.hasUnread {
        DFBadge.dot.foregroundStyle(theme.colors.primary)
    }
}

Accessibility — a dot is purely visual by default. If it conveys information:

DFBadge.dot
    .accessibilityLabel("Unread")
    .accessibilityAddTraits(.updatesFrequently)

Complete Code Examples

1. Unread Count Badge on Nav Bar Icon

struct InboxToolbarButton: View {
    @Environment(\.dfTheme) private var theme
    let unreadCount: Int

    var body: some View {
        Button { } label: {
            ZStack(alignment: .topTrailing) {
                DFIcon("envelope.fill", size: .md)
                    .padding(.trailing, unreadCount > 0 ? 8 : 0)

                if unreadCount > 0 {
                    DFBadge(count: unreadCount, color: theme.colors.error)
                        .offset(x: 8, y: -6)
                        .transition(.scale.combined(with: .opacity))
                }
            }
            .animation(.spring(duration: 0.25), value: unreadCount)
        }
        .accessibilityLabel(
            unreadCount == 0
                ? "Inbox"
                : "Inbox, \(unreadCount > 99 ? "99 or more" : "\(unreadCount)") unread"
        )
    }
}

2. Status Labels in a Data List

struct ProjectListView: View {
    @Environment(\.dfTheme) private var theme
    let projects: [Project]

    var body: some View {
        DFList(projects) { project in
            HStack {
                VStack(alignment: .leading) {
                    DFText(project.name, style: .headline)
                    DFText(project.description, style: .caption)
                }
                Spacer()
                DFBadge(text: project.status.label, color: statusColor(project.status))
                    .dfBadgeStyle(.tinted)
            }
        }
    }

    func statusColor(_ status: Project.Status) -> Color {
        switch status {
        case .active:    theme.colors.success
        case .pending:   theme.colors.warning
        case .archived:  Color.gray
        case .failed:    theme.colors.error
        }
    }
}

3. "Pro" / "Beta" Badge Next to a Menu Item

VStack(alignment: .leading, spacing: 0) {
    HStack(spacing: theme.spacing.md) {
        DFIcon("wand.and.stars", size: .md)
        DFText("AI Suggestions", style: .body)
        DFBadge(text: "Pro", color: theme.colors.primary)
        Spacer()
    }
    .padding(theme.spacing.md)

    DFDivider()

    HStack(spacing: theme.spacing.md) {
        DFIcon("chart.bar.xaxis", size: .md)
        DFText("Advanced Analytics", style: .body)
        DFBadge(text: "Beta", color: theme.colors.warning).dfBadgeStyle(.outlined)
        Spacer()
    }
    .padding(theme.spacing.md)
}

4. Tag Cloud of Interest Categories

struct InterestTagCloud: View {
    @Environment(\.dfTheme) private var theme
    let interests: [Interest]
    @State private var selected: Set<String> = []

    var body: some View {
        FlowLayout(spacing: theme.spacing.sm) {
            ForEach(interests) { interest in
                let isSelected = selected.contains(interest.id)
                Button {
                    if isSelected { selected.remove(interest.id) }
                    else { selected.insert(interest.id) }
                } label: {
                    DFBadge(text: interest.name, color: interest.color)
                        .dfBadgeStyle(isSelected ? .filled : .tinted)
                        .animation(.easeInOut(duration: 0.15), value: isSelected)
                }
                .buttonStyle(.plain)
                .accessibilityLabel("\(interest.name), \(isSelected ? "selected" : "not selected")")
            }
        }
    }
}

5. Combined Avatar + Badge for Team Members with Role Labels

struct TeamMemberRow: View {
    @Environment(\.dfTheme) private var theme
    let member: TeamMember

    var body: some View {
        HStack(spacing: theme.spacing.md) {
            DFAvatar(url: member.avatarURL, size: 44)
                .overlay(alignment: .bottomTrailing) {
                    DFBadge.dot
                        .foregroundStyle(member.isOnline ? theme.colors.success : Color.gray)
                        .background(Circle().fill(theme.colors.background).padding(-2))
                }

            VStack(alignment: .leading, spacing: 2) {
                HStack(spacing: theme.spacing.xs) {
                    DFText(member.name, style: .body)
                    DFBadge(text: member.role.label, color: member.role.color)
                        .dfBadgeStyle(.tinted)
                }
                DFText(member.email, style: .caption)
            }

            Spacer()

            if member.isPendingAcceptance {
                DFBadge(text: "Invited", color: theme.colors.warning)
                    .dfBadgeStyle(.outlined)
            }
        }
        .padding(theme.spacing.md)
    }
}

6. Multiple Badges in a Filter Bar (with Remove)

struct FilterBar: View {
    @Environment(\.dfTheme) private var theme
    @Binding var activeFilters: [Filter]

    var body: some View {
        if !activeFilters.isEmpty {
            ScrollView(.horizontal, showsIndicators: false) {
                HStack(spacing: theme.spacing.sm) {
                    ForEach(activeFilters) { filter in
                        HStack(spacing: theme.spacing.xs) {
                            DFBadge(text: filter.label, color: theme.colors.primary)
                                .dfBadgeStyle(.tinted)

                            Button {
                                withAnimation(.spring(duration: 0.2)) {
                                    activeFilters.removeAll { $0.id == filter.id }
                                }
                            } label: {
                                DFIcon("xmark", size: .sm, color: theme.colors.primary)
                            }
                            .accessibilityLabel("Remove \(filter.label) filter")
                            .buttonStyle(.plain)
                        }
                        .padding(.horizontal, theme.spacing.sm)
                        .padding(.vertical, theme.spacing.xs)
                        .background(
                            RoundedRectangle(cornerRadius: theme.radius.full)
                                .fill(theme.colors.primary.opacity(0.08))
                        )
                    }

                    Button {
                        withAnimation { activeFilters.removeAll() }
                    } label: {
                        DFText("Clear all", style: .caption)
                            .foregroundStyle(theme.colors.error)
                    }
                }
                .padding(.horizontal, theme.spacing.md)
            }
        }
    }
}

7. Dot Badge as Unread Indicator in Chat List

struct ChatListRow: View {
    @Environment(\.dfTheme) private var theme
    let conversation: Conversation

    var body: some View {
        HStack(spacing: theme.spacing.md) {
            DFAvatar(url: conversation.participantAvatar, size: 48)
                .overlay(alignment: .bottomTrailing) {
                    if conversation.participant.isOnline {
                        DFBadge.dot
                            .foregroundStyle(theme.colors.success)
                            .background(Circle().fill(theme.colors.background).padding(-2))
                    }
                }

            VStack(alignment: .leading, spacing: theme.spacing.xs) {
                HStack {
                    DFText(conversation.participantName,
                           style: conversation.hasUnread ? .headline : .body)
                    Spacer()
                    DFText(conversation.lastMessageTime, style: .caption)
                }

                HStack {
                    DFText(conversation.lastMessagePreview, style: .caption)
                        .lineLimit(1)
                        .foregroundStyle(conversation.hasUnread
                            ? theme.colors.textPrimary
                            : theme.colors.textSecondary)
                    Spacer()
                    if conversation.hasUnread {
                        DFBadge.dot.foregroundStyle(theme.colors.primary)
                    }
                }
            }
        }
        .padding(.vertical, theme.spacing.sm)
    }
}

8. Outlined Badge for Version / Build Info in Settings

struct AboutSection: View {
    @Environment(\.dfTheme) private var theme
    let appVersion: String
    let buildNumber: String

    var body: some View {
        DFCard {
            VStack(alignment: .leading, spacing: theme.spacing.md) {
                DFText("About", style: .headline)
                DFDivider()

                HStack {
                    DFText("Version", style: .body)
                    Spacer()
                    DFBadge(text: "v\(appVersion)").dfBadgeStyle(.outlined)
                }

                HStack {
                    DFText("Build", style: .body)
                    Spacer()
                    DFBadge(text: "#\(buildNumber)", color: theme.colors.textSecondary)
                        .dfBadgeStyle(.outlined)
                }
            }
            .padding(theme.spacing.lg)
        }
    }
}

Accessibility

Labels for Count Badges

DFBadge(count: count)
    .accessibilityLabel(
        count > 99
            ? "99 or more unread notifications"
            : "\(count) unread notification\(count == 1 ? "" : "s")"
    )

Hiding Decorative Badges

DFListRow("Jamie Lin — Active") {
    DFBadge(text: "Active", color: theme.colors.success)
        .dfBadgeStyle(.tinted)
        .accessibilityHidden(true)   // row label already conveys status
}

Dynamic Content

DFBadge(count: liveViewerCount)
    .accessibilityLabel("\(liveViewerCount) viewers")
    .accessibilityAddTraits(.updatesFrequently)

Minimum Tap Target for Interactive Badges

Button { toggleFilter(tag) } label: {
    DFBadge(text: tag.name, color: tag.color).dfBadgeStyle(.tinted)
}
.frame(minWidth: 44, minHeight: 44)
.contentShape(Rectangle())

See also: Primitives · Avatars · Lists-Tables-and-Data · Navigation · Theming

Clone this wiki locally