Skip to content

Avatars

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

DFAvatar

DFAvatar is the standard identity component in DesignFoundation. It renders a person, organization, or entity as a circular (or rounded) graphic — from a remote image, extracted initials, an explicit initials string, or an SF Symbol icon. A built-in fallback chain ensures something meaningful always renders, even when network images fail.

Also covered in: Primitives (brief reference)

Related: Lists-Tables-and-Data, Navigation, Theming


Table of Contents

  1. Overview
  2. Initializer Reference
  3. Size Guide
  4. Presence Badges
  5. Ring and Stroke Variants
  6. Style Variants
  7. Fallback Behavior
  8. Initials Algorithm
  9. Avatar Stacks and Groups
  10. Complete Examples

Overview

import DesignFoundation

// The simplest possible avatar — name string in, initials out
DFAvatar(name: "Jamie Lin")

DFAvatar follows a fallback chain when resolving what to display:

URL image (loads async)
  └─► initials derived from name:
          └─► explicit initials string (if provided)
                  └─► SF Symbol icon (symbol variant)
                          └─► generic person silhouette (last resort)

This means you can pass a URL and a name together — if the image fails to load, the user still sees "JL" instead of a broken placeholder.

Accessibility

DFAvatar automatically sets an accessibility label from the source you provide:

Source Accessibility label
name: "Jamie Lin" "Jamie Lin"
initials: "DF" "DF"
url:, name: "Jamie Lin" "Jamie Lin"
symbol: "person.fill" "Person" (localized SF Symbol name)

Override the label at call-site using .accessibilityLabel("Team lead avatar") if context requires a richer description.

Theme Integration

All DFAvatar variants inherit the active Theming automatically. The initials fill color is theme.colors.primary by default. The initials text color is computed for maximum contrast against that fill. Ring colors, presence dot borders, and glass material all respond to the active color scheme (light/dark) without any configuration.

@Environment(\.dfTheme) private var theme

// Override fill to accent color for a secondary persona
DFAvatar(name: "Bot User")
    .foregroundColor(theme.colors.accent)

Initializer Reference

1. Name (Initials Extraction)

DFAvatar(name: "Jamie Lin")
DFAvatar(name: "Jamie Lin", size: 40)
DFAvatar(name: "Jamie Lin", size: 40, badge: .online)
DFAvatar(name: "Jamie Lin", size: 40, badge: .online, strokeColor: .white, strokeWidth: 2)

Use when: you have a full display name and want automatic initials. This is the most common form.


2. Explicit Initials

DFAvatar(initials: "DF")
DFAvatar(initials: "DF", size: 32)
DFAvatar(initials: "A", size: 24)

Use when: the name string would produce the wrong initials (edge cases, company names, usernames), or when you already have initials stored in your model and want deterministic output.

// Username "xX_DarkLord_Xx" → initials extraction would be ugly
DFAvatar(initials: user.initials ?? "?", size: 36)

3. URL with Fallback

DFAvatar(url: profileURL, name: "Jamie Lin", size: 40)

// URL only — falls back to generic silhouette if no name provided
DFAvatar(url: profileURL, size: 40)

// URL + explicit initials fallback
DFAvatar(url: profileURL, initials: "JL", size: 40)

Use when: displaying a user with a remote profile image. Always supply name: or initials: as the fallback. The image loads asynchronously; during loading the initials fill renders immediately so there is no layout shift.

// In a chat message header
DFAvatar(
    url: message.sender.avatarURL,
    name: message.sender.displayName,
    size: 36,
    badge: message.sender.isOnline ? .online : .offline
)

4. SF Symbol Icon

DFAvatar(symbol: "person.fill", size: 40)
DFAvatar(symbol: "building.2.fill", size: 40)
DFAvatar(symbol: "doc.fill", size: 32)
DFAvatar(symbol: "cpu.fill", size: 36)

Use when: the entity is not a person — a workspace, organization, bot, file, or system resource.

// Non-person entities
DFAvatar(symbol: "building.2.fill", size: 40)   // organization
DFAvatar(symbol: "cpu.fill", size: 36)           // AI agent / bot
DFAvatar(symbol: "folder.fill", size: 32)        // file reference
DFAvatar(symbol: "link", size: 32)               // external service

Size Guide

Size (pt) Context Example usage
24 Navigation bar, compact rows, breadcrumbs DFAvatar(name: user.name, size: 24)
32 List rows, comment threads, inline chips DFAvatar(name: user.name, size: 32)
36 Default — sidebar rows, table cells DFAvatar(name: user.name)
40 Cards, popover headers, search results DFAvatar(name: user.name, size: 40)
56 Profile summary, sheet headers DFAvatar(name: user.name, size: 56)
80 Large profile, onboarding confirmation DFAvatar(name: user.name, size: 80)
120 Hero / full profile screen DFAvatar(name: user.name, size: 120)

The initials font size scales automatically with the avatar diameter.


Presence Badges

A presence badge is a small colored dot anchored to the bottom-trailing corner of the avatar.

DFPresenceBadge Cases

public enum DFPresenceBadge: Sendable {
    case online    // green  — active, available
    case away      // amber  — signed in but idle / away
    case busy      // red    — do not disturb, in a meeting
    case offline   // gray   — signed out or no recent activity
}

Usage

DFAvatar(name: "Jamie Lin", badge: .online)
DFAvatar(name: "Sam Carter", badge: .away)
DFAvatar(name: "Riley Park", badge: .busy)
DFAvatar(name: "Morgan Lee", badge: .offline)

// Dynamic badge from model
DFAvatar(name: contact.name, size: 36, badge: contact.presenceBadge)
extension Contact {
    var presenceBadge: DFPresenceBadge? {
        guard isVisible else { return nil }
        switch status {
        case .online:  return .online
        case .away:    return .away
        case .busy:    return .busy
        case .offline: return .offline
        }
    }
}

The badge diameter scales automatically — roughly 28% of the avatar diameter — with a background-color ring to lift it visually off the avatar fill.

Combining Badge with Stroke Ring

// White ring + presence dot
DFAvatar(
    name: "Jamie Lin",
    size: 40,
    badge: .online,
    strokeColor: .white,
    strokeWidth: 2
)

// Primary ring (selected) + away badge
DFAvatar(
    url: user.avatarURL,
    name: user.name,
    size: 40,
    badge: .away,
    strokeColor: theme.colors.primary,
    strokeWidth: 2
)

Ring and Stroke Variants

Pattern strokeColor strokeWidth When to use
Avatar stack separator .white 2 Overlapping avatar groups
Selected user theme.colors.primary 2 Active user in sidebar, DMs
Error / flagged theme.colors.error 2 Suspended, blocked user
Dark mode stack theme.colors.background 2 Stacks on dark surfaces
// No ring (default)
DFAvatar(name: "Jamie Lin", size: 40)

// White ring — use in avatar stacks to separate overlapping faces
DFAvatar(name: "Jamie Lin", size: 40, strokeColor: .white, strokeWidth: 2)

// Primary color ring — use to highlight the selected/active user
DFAvatar(name: "Jamie Lin", size: 40, strokeColor: theme.colors.primary, strokeWidth: 2)

Style Variants

Apply with .dfAvatarStyle(_:):

Style Shape Fill Best for
.circle Circle Solid color fill People, standard contexts (default)
.rounded Rounded rectangle Solid color fill Organizations, apps, non-person entities
.ring Circle outline only Transparent Decorative, empty/unset states
.glass Circle iOS 26+ / macOS 26+ material visionOS surfaces, liquid glass UIs
// Person — circle (default)
DFAvatar(name: "Jamie Lin", size: 40)
    .dfAvatarStyle(.circle)

// Organization — rounded rect reads as "not a person"
DFAvatar(symbol: "building.2.fill", size: 40)
    .dfAvatarStyle(.rounded)

// visionOS / glass surface
DFAvatar(url: user.avatarURL, name: user.name, size: 40)
    .dfAvatarStyle(.glass)

.glass requires iOS 26+ or macOS 26+. Falls back to .circle on earlier OS automatically.


Fallback Behavior

DFAvatar never renders a broken state:

  • URL fails to load → renders initials (from name: or initials:) immediately — no layout shift
  • Empty name string → falls back to generic person silhouette
  • Whitespace-only name → treated as empty
  • Single character nameDFAvatar(name: "Alex")"A"
  • Long initials string → only first two characters rendered: initials: "ABCDE""AB"

Initials Algorithm

When you pass name:, DesignFoundation parses it using this algorithm:

  1. Trim leading/trailing whitespace
  2. Split by whitespace (one or more spaces)
  3. Filter empty tokens
  4. Extract: first character of the first token + first character of the last token
  5. Uppercase both characters
  6. If only one token, use only the first character
Input name: Initials rendered
"Jamie Lin" "JL"
"Alex" "A"
"van Gogh" "VG"
"Mary Jane Watson" "MW" (first + last)
"j. r. r. tolkien" "JT"
"李 明" "李明" (Unicode-safe)
"" fallback silhouette

To override the algorithm, use initials: directly:

DFAvatar(initials: user.initials, size: 36)

Avatar Stacks and Groups

Build stacks using a negative-spacing HStack. A stroke ring on each avatar separates them visually:

struct AvatarStack: View {
    let users: [User]
    let maxVisible: Int = 4

    @Environment(\.dfTheme) private var theme

    var body: some View {
        HStack(spacing: -10) {
            ForEach(users.prefix(maxVisible)) { user in
                DFAvatar(
                    url: user.avatarURL,
                    name: user.name,
                    size: 32,
                    strokeColor: .white,
                    strokeWidth: 2
                )
            }

            // Overflow count bubble
            if users.count > maxVisible {
                let overflow = users.count - maxVisible
                DFAvatar(initials: "+\(overflow)", size: 32, strokeColor: .white, strokeWidth: 2)
                    .foregroundColor(theme.colors.textSecondary)
            }
        }
    }
}

On dark surfaces, swap the white ring for the background color:

DFAvatar(url: user.avatarURL, name: user.name, size: 32,
         strokeColor: theme.colors.background, strokeWidth: 2)

Complete Examples

1. Initials Avatar in a List Row

struct ContactRow: View {
    let contact: Contact

    var body: some View {
        DFListRow(contact.displayName, subtitle: contact.email) {
            DFAvatar(name: contact.displayName, size: 36)
        }
    }
}

2. URL Avatar with Presence Badge in a Chat Sidebar

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

    var body: some View {
        HStack(spacing: theme.spacing.sm) {
            DFAvatar(
                url: conversation.participant.avatarURL,
                name: conversation.participant.name,
                size: 40,
                badge: conversation.participant.presenceBadge
            )

            VStack(alignment: .leading, spacing: 2) {
                DFText(conversation.participant.name, style: .headline)
                DFText(conversation.lastMessage, style: .caption)
                    .lineLimit(1)
            }

            Spacer()

            if conversation.unreadCount > 0 {
                DFBadge(text: "\(conversation.unreadCount)", color: .blue)
            }
        }
        .padding(.horizontal, theme.spacing.md)
        .padding(.vertical, theme.spacing.sm)
    }
}

3. Large Profile Header with Stroke Ring and Edit Button Overlay

struct ProfileHeader: View {
    let user: User
    @Binding var isEditing: Bool
    @Environment(\.dfTheme) private var theme

    var body: some View {
        ZStack(alignment: .bottomTrailing) {
            DFAvatar(
                url: user.avatarURL,
                name: user.name,
                size: 80,
                strokeColor: theme.colors.primary,
                strokeWidth: 3
            )

            Button {
                isEditing = true
            } label: {
                DFIcon("pencil", size: .sm, color: .white)
                    .padding(6)
                    .background(theme.colors.primary)
                    .clipShape(Circle())
                    .overlay(Circle().stroke(theme.colors.background, lineWidth: 2))
            }
            .offset(x: 4, y: 4)
        }
    }
}

4. Avatar Stack for "Viewed By" Indicators

struct ViewedByRow: View {
    let viewers: [User]
    private let maxVisible = 4
    @Environment(\.dfTheme) private var theme

    var body: some View {
        HStack(spacing: theme.spacing.sm) {
            HStack(spacing: -10) {
                ForEach(viewers.prefix(maxVisible)) { user in
                    DFAvatar(url: user.avatarURL, name: user.name, size: 28,
                             strokeColor: theme.colors.background, strokeWidth: 2)
                }

                if viewers.count > maxVisible {
                    let overflow = viewers.count - maxVisible
                    ZStack {
                        Circle()
                            .fill(theme.colors.surface)
                            .overlay(Circle().stroke(theme.colors.background, lineWidth: 2))
                            .frame(width: 28, height: 28)
                        DFText("+\(overflow)", style: .caption)
                            .foregroundColor(theme.colors.textSecondary)
                    }
                }
            }

            DFText(viewedByLabel, style: .caption)
                .foregroundColor(theme.colors.textSecondary)
        }
    }

    private var viewedByLabel: String {
        switch viewers.count {
        case 0: return "No views yet"
        case 1: return "\(viewers[0].firstName) viewed this"
        case 2: return "\(viewers[0].firstName) and \(viewers[1].firstName) viewed this"
        default: return "Viewed by \(viewers.count) people"
        }
    }
}

5. Navigation Bar Avatar Button

struct MainContentView: View {
    let currentUser: User
    @State private var showProfile = false

    var body: some View {
        DocumentListView()
            .dfNavigationBar(title: "Documents") {
                Button {
                    showProfile = true
                } label: {
                    DFAvatar(url: currentUser.avatarURL, name: currentUser.name, size: 28)
                }
                .accessibilityLabel("Open profile for \(currentUser.name)")
            }
            .sheet(isPresented: $showProfile) {
                ProfileView(user: currentUser)
            }
    }
}

6. System Icon Avatar for Non-Person Entities

struct IntegrationRow: View {
    let integration: Integration
    @Environment(\.dfTheme) private var theme

    var body: some View {
        HStack(spacing: theme.spacing.md) {
            DFAvatar(symbol: integration.sfSymbol, size: 40)
                .dfAvatarStyle(.rounded)
                .foregroundColor(integration.accentColor)

            VStack(alignment: .leading, spacing: 2) {
                DFText(integration.name, style: .headline)
                DFText(integration.description, style: .caption)
            }

            Spacer()

            DFBadge(
                text: integration.isConnected ? "Connected" : "Not connected",
                color: integration.isConnected ? .green : .gray
            )
        }
        .padding(theme.spacing.md)
    }
}

7. Glass Style Avatar in a visionOS Surface

// Falls back to .circle on iOS < 26 / macOS < 26 automatically
struct SpatialChatBubble: View {
    let message: ChatMessage
    @Environment(\.dfTheme) private var theme

    var body: some View {
        HStack(alignment: .top, spacing: theme.spacing.md) {
            DFAvatar(
                url: message.sender.avatarURL,
                name: message.sender.name,
                size: 44,
                badge: message.sender.presenceBadge
            )
            .dfAvatarStyle(.glass)

            DFCard {
                VStack(alignment: .leading, spacing: theme.spacing.xs) {
                    DFText(message.sender.name, style: .caption)
                        .foregroundColor(theme.colors.textSecondary)
                    DFText(message.body, style: .body)
                }
            }

            Spacer()
        }
        .padding(theme.spacing.md)
    }
}

8. Animated Loading State: Skeleton Transitioning to Avatar

struct AsyncAvatarView: View {
    let userID: String
    @State private var user: User? = nil
    @State private var isLoading = true

    var body: some View {
        Group {
            if isLoading {
                DFSkeleton(width: 40, height: 40, shape: .circle)
            } else if let user {
                DFAvatar(
                    url: user.avatarURL,
                    name: user.name,
                    size: 40,
                    badge: user.presenceBadge
                )
                .transition(.opacity.combined(with: .scale(scale: 0.9)))
            }
        }
        .animation(.easeOut(duration: 0.2), value: isLoading)
        .task {
            user = await UserService.fetch(id: userID)
            withAnimation { isLoading = false }
        }
    }
}

API Quick Reference

// Initializers
DFAvatar(name: String, size: CGFloat = 36, badge: DFPresenceBadge? = nil,
         strokeColor: Color? = nil, strokeWidth: CGFloat = 2)

DFAvatar(initials: String, size: CGFloat = 36, badge: DFPresenceBadge? = nil,
         strokeColor: Color? = nil, strokeWidth: CGFloat = 2)

DFAvatar(url: URL?, name: String? = nil, size: CGFloat = 36,
         badge: DFPresenceBadge? = nil,
         strokeColor: Color? = nil, strokeWidth: CGFloat = 2)

DFAvatar(symbol: String, size: CGFloat = 36, badge: DFPresenceBadge? = nil,
         strokeColor: Color? = nil, strokeWidth: CGFloat = 2)

// Style modifier
.dfAvatarStyle(.circle)    // default
.dfAvatarStyle(.rounded)
.dfAvatarStyle(.ring)
.dfAvatarStyle(.glass)     // iOS 26+ / macOS 26+ (auto-degrades)

// Custom fill color
.foregroundColor(theme.colors.accent)

// Presence
public enum DFPresenceBadge: Sendable {
    case online, away, busy, offline
}

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

Clone this wiki locally