SwiftUI Avatar Component

Avatar with an initials fallback, an optional presence dot, and a stacked group variant.

A
J
M
A
J
M
T
S
+7

Variants: Single, Group

Install the free SwiftUI avatar

  1. Add SwiftKitUI.swift to your app once. It holds the tokens and motion curves.
  2. Copy SKAvatar.swift below into your project. It needs nothing else.

Usage

Usage.swift
SKAvatar(name: "Ahmed Gagan")
SKAvatar(name: "Ada Lovelace", image: img, presence: .online)
SKAvatarGroup(names: ["Ada", "Alan", "Grace", "Katherine"])

Full source · 143 lines · MIT

SKAvatar.swift
//
//  SKAvatar.swift
//  Swift Kit UI
//
//  Requires: SwiftKitUI.swift
//
//  Avatar with an initials fallback, an optional presence dot, and a stacked
//  group variant.
//
//  Initials are coloured deterministically from the name, so the same person is
//  always the same colour across the app — random colours per render is the
//  usual bug and it makes people look like different users.
//
//  Usage:
//      SKAvatar(name: "Ahmed Gagan")
//      SKAvatar(name: "Ada Lovelace", image: img, presence: .online)
//      SKAvatarGroup(names: ["Ada", "Alan", "Grace", "Katherine"])
//

import SwiftUI

public struct SKAvatar: View {
    public enum Presence: Sendable {
        case online, away, offline, none

        var color: Color? {
            switch self {
            case .online: SK.Palette.success
            case .away: SK.Palette.warning
            case .offline: SK.Palette.labelTertiary
            case .none: nil
            }
        }
    }

    private let name: String
    private let image: Image?
    private let size: CGFloat
    private let presence: Presence

    public init(name: String, image: Image? = nil, size: CGFloat = 44, presence: Presence = .none) {
        self.name = name
        self.image = image
        self.size = size
        self.presence = presence
    }

    private var initials: String {
        let parts = name.split(separator: " ").prefix(2)
        let letters = parts.compactMap(\.first).map(String.init)
        return letters.isEmpty ? "?" : letters.joined().uppercased()
    }

    /// Stable hue from the name. Same person, same colour, every time.
    private var tint: Color {
        let hash = name.unicodeScalars.reduce(UInt32(7)) { ($0 &* 31) &+ $1.value }
        return Color(hue: Double(hash % 360) / 360, saturation: 0.55, brightness: 0.72)
    }

    public var body: some View {
        ZStack {
            if let image {
                image.resizable().scaledToFill()
            } else {
                tint
                Text(initials)
                    .font(.system(size: size * 0.38, weight: .semibold, design: .rounded))
                    .foregroundStyle(.white)
            }
        }
        .frame(width: size, height: size)
        .clipShape(Circle())
        .overlay { Circle().strokeBorder(.white.opacity(0.25), lineWidth: SK.Stroke.hairline) }
        .overlay(alignment: .bottomTrailing) { presenceDot }
        .accessibilityLabel(name)
    }

    @ViewBuilder
    private var presenceDot: some View {
        if let color = presence.color {
            Circle()
                .fill(color)
                .frame(width: size * 0.28, height: size * 0.28)
                // Ring in the surface colour so the dot reads as cut out of the
                // avatar rather than floating on top of it.
                .overlay { Circle().strokeBorder(SK.Palette.surface, lineWidth: size * 0.06) }
                .offset(x: size * 0.02, y: size * 0.02)
        }
    }
}

/// Overlapping avatars with a +N overflow badge.
public struct SKAvatarGroup: View {
    private let names: [String]
    private let size: CGFloat
    private let maxVisible: Int

    public init(names: [String], size: CGFloat = 36, maxVisible: Int = 3) {
        self.names = names
        self.size = size
        self.maxVisible = maxVisible
    }

    public var body: some View {
        HStack(spacing: -size * 0.32) {
            ForEach(Array(names.prefix(maxVisible).enumerated()), id: \.offset) { _, name in
                SKAvatar(name: name, size: size)
                    .overlay { Circle().strokeBorder(SK.Palette.surface, lineWidth: 2) }
            }
            if names.count > maxVisible {
                Text("+\(names.count - maxVisible)")
                    .font(.system(size: size * 0.32, weight: .semibold, design: .rounded))
                    .foregroundStyle(SK.Palette.labelSecondary)
                    .frame(width: size, height: size)
                    .background(Circle().fill(SK.Palette.fill))
                    .overlay { Circle().strokeBorder(SK.Palette.surface, lineWidth: 2) }
            }
        }
        .accessibilityElement(children: .combine)
        .accessibilityLabel("\(names.count) people")
    }
}

#Preview("SKAvatar") {
    VStack(spacing: SK.Space.xl) {
        HStack(spacing: SK.Space.lg) {
            SKAvatar(name: "Ahmed Gagan", presence: .online)
            SKAvatar(name: "Ada Lovelace", presence: .away)
            SKAvatar(name: "Grace Hopper", presence: .offline)
            SKAvatar(name: "Katherine Johnson")
        }
        HStack(spacing: SK.Space.lg) {
            SKAvatar(name: "Alan Turing", size: 28)
            SKAvatar(name: "Alan Turing", size: 44)
            SKAvatar(name: "Alan Turing", size: 64, presence: .online)
        }
        SKAvatarGroup(names: ["Ada", "Alan", "Grace", "Katherine", "Margaret"])
    }
    .padding(SK.Space.xl)
    .frame(maxWidth: .infinity, maxHeight: .infinity)
    .background(SK.Palette.surfaceSunken)
}

Questions

Is this SwiftUI avatar free for commercial apps?
Yes. Swift Kit UI is MIT licensed, so you can use it in personal and commercial apps with no payment or sign-up. Keep the licence notice with the code.
What does it depend on?
SKAvatar.swift depends only on SwiftKitUI.swift, a single foundation file with the design tokens and motion curves. There are no third-party packages.
Which iOS versions does it support?
iOS 17 and later, plus macOS 14.

Building a whole app? The Swift Kit adds auth, payments and onboarding, ready to ship.