-
Notifications
You must be signed in to change notification settings - Fork 0
Avatars
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
- Overview
- Initializer Reference
- Size Guide
- Presence Badges
- Ring and Stroke Variants
- Style Variants
- Fallback Behavior
- Initials Algorithm
- Avatar Stacks and Groups
- Complete Examples
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.
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.
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)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.
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)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
)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 (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.
A presence badge is a small colored dot anchored to the bottom-trailing corner of the avatar.
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
}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.
// 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
)| 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)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)
.glassrequires iOS 26+ or macOS 26+. Falls back to.circleon earlier OS automatically.
DFAvatar never renders a broken state:
-
URL fails to load → renders initials (from
name:orinitials:) immediately — no layout shift - Empty name string → falls back to generic person silhouette
- Whitespace-only name → treated as empty
-
Single character name →
DFAvatar(name: "Alex")→"A" -
Long initials string → only first two characters rendered:
initials: "ABCDE"→"AB"
When you pass name:, DesignFoundation parses it using this algorithm:
- Trim leading/trailing whitespace
- Split by whitespace (one or more spaces)
- Filter empty tokens
- Extract: first character of the first token + first character of the last token
- Uppercase both characters
- 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)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)struct ContactRow: View {
let contact: Contact
var body: some View {
DFListRow(contact.displayName, subtitle: contact.email) {
DFAvatar(name: contact.displayName, size: 36)
}
}
}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)
}
}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)
}
}
}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"
}
}
}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)
}
}
}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)
}
}// 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)
}
}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 }
}
}
}// 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