-
Notifications
You must be signed in to change notification settings - Fork 0
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
- Overview
- Initializer Reference
- Style Variants
- Color System
- The 99+ Cap
- Positioning Patterns
- Tinted Style Deep-Dive
- Dot Badge
- Complete Code Examples
- Accessibility
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.dotRenders 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)
}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")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) // offlineApply 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 |
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 |
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+"DFIcon("bell.fill")
.overlay(alignment: .topTrailing) {
if notificationCount > 0 {
DFBadge(count: notificationCount, color: theme.colors.error)
.offset(x: 8, y: -8)
}
}HStack(alignment: .firstTextBaseline, spacing: theme.spacing.sm) {
DFText("Subscription", style: .body)
DFBadge(text: "Pro", color: theme.colors.primary)
}DFAvatar(name: "Jamie Lin")
.overlay(alignment: .bottomTrailing) {
DFBadge.dot
.foregroundStyle(isOnline ? theme.colors.success : .gray)
.background(Circle().fill(theme.colors.background).padding(-2))
}DFListRow(
"Messages",
subtitle: "3 unread threads",
icon: "message.fill",
accessory: .custom(AnyView(
DFBadge(count: 3, color: theme.colors.error)
))
).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)
}
}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)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"
)
}
}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
}
}
}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)
}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")")
}
}
}
}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)
}
}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)
}
}
}
}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)
}
}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)
}
}
}DFBadge(count: count)
.accessibilityLabel(
count > 99
? "99 or more unread notifications"
: "\(count) unread notification\(count == 1 ? "" : "s")"
)DFListRow("Jamie Lin — Active") {
DFBadge(text: "Active", color: theme.colors.success)
.dfBadgeStyle(.tinted)
.accessibilityHidden(true) // row label already conveys status
}DFBadge(count: liveViewerCount)
.accessibilityLabel("\(liveViewerCount) viewers")
.accessibilityAddTraits(.updatesFrequently)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