Story Ring Avatar
An avatar with a gradient ring that spins while there are unseen stories.
- Profiles
- iOS 18.0+
- intermediate
- Origin
- Original work
- Found by
- avatar · story ring · profile picture · gradient ring · unseen · instagram · avatar · anillo de historias · foto de perfil · anillo degradado · sin ver · instagram
Description
Section titled “Description”An avatar with a gradient ring that spins while there are unseen stories.
placeholder shows while the image loads or when there is no URL. With Reduce Motion on, the ring stays still.
struct StoryRingAvatarDemo: View { private struct Friend: Identifiable { let id: String let symbol: String let colors: [Color] }
private let friends = [ Friend(id: "Ana", symbol: "person.fill", colors: [.pink, .orange]), Friend(id: "Leo", symbol: "pawprint.fill", colors: [.teal, .blue]), Friend(id: "Mia", symbol: "camera.fill", colors: [.purple, .indigo]), Friend(id: "Noa", symbol: "music.note", colors: [.green, .mint]), ]
@State private var seen: Set<String> = ["Noa"]
var body: some View { VStack(spacing: 40) { Spacer()
ScrollView(.horizontal) { HStack(spacing: 18) { ForEach(friends) { friend in Button { if seen.remove(friend.id) == nil { seen.insert(friend.id) } } label: { VStack(spacing: 8) { avatar(for: friend, size: 74) Text(friend.id) .font(.caption.weight(.medium)) .foregroundStyle(.primary) } } .buttonStyle(.plain) .accessibilityLabel(friend.id) .accessibilityValue(seen.contains(friend.id) ? "No new stories" : "New stories") } } .padding(.horizontal, 24) .frame(maxWidth: .infinity) } .scrollIndicators(.hidden) .fixedSize(horizontal: false, vertical: true)
avatar(for: friends[0], size: 190)
Text("Tap a friend to mark their stories as seen.") .font(.body) .foregroundStyle(.secondary) .multilineTextAlignment(.center) .padding(.horizontal, 32)
Spacer() } }
private func avatar(for friend: Friend, size: CGFloat) -> some View { StoryRingAvatar(name: friend.id, hasUnseenStories: !seen.contains(friend.id), size: size) { ZStack { LinearGradient(colors: friend.colors, startPoint: .topLeading, endPoint: .bottomTrailing) Image(systemName: friend.symbol) .font(.system(size: size * 0.38)) .foregroundStyle(.white) } } }}API reference
Section titled “API reference”| Parameter | Type | Default |
|---|---|---|
name | String | Required |
url | URL? | nil |
hasUnseenStories | Bool | true |
size | CGFloat | 72 |
colors | [Color] | [.yellow, .orange, .pink, .purple, .yellow] |
placeholder | () -> Placeholder | Required |
Copy this code into your project, or download the file.
// SPDX-License-Identifier: MIT// Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors
import SwiftUI
/// An avatar with a gradient ring that spins while there are unseen stories.////// `placeholder` shows while the image loads or when there is no URL. With Reduce Motion on,/// the ring stays still.struct StoryRingAvatar<Placeholder: View>: View { let name: String var url: URL? var hasUnseenStories: Bool = true var size: CGFloat = 72 var colors: [Color] = [.yellow, .orange, .pink, .purple, .yellow] @ViewBuilder var placeholder: () -> Placeholder
@Environment(\.accessibilityReduceMotion) private var reduceMotion
private var spins: Bool { hasUnseenStories && !reduceMotion }
var body: some View { let diameter = max(size, 0) let ringWidth = diameter * 0.05 let photoSize = max(diameter - ringWidth * 4, 0)
ZStack { Circle() .strokeBorder(.quaternary, lineWidth: max(ringWidth * 0.5, 1))
TimelineView(.animation(minimumInterval: 1.0 / 30.0, paused: !spins)) { timeline in Circle() .strokeBorder(AngularGradient(colors: colors, center: .center), lineWidth: ringWidth) .rotationEffect(.degrees(spins ? angle(at: timeline.date) : 0)) } .opacity(hasUnseenStories ? 1 : 0)
AsyncImage(url: url) { phase in if let image = phase.image { image .resizable() .scaledToFill() } else { placeholder() } } .frame(width: photoSize, height: photoSize) .clipShape(.circle) } .frame(width: diameter, height: diameter) .animation(reduceMotion ? nil : .easeInOut(duration: 0.3), value: hasUnseenStories) .accessibilityElement(children: .ignore) .accessibilityLabel(name) .accessibilityValue(hasUnseenStories ? "New stories" : "No new stories") }
private func angle(at date: Date) -> Double { (date.timeIntervalSinceReferenceDate * 90).truncatingRemainder(dividingBy: 360) }}
// MARK: - Demo
struct StoryRingAvatarDemo: View { private struct Friend: Identifiable { let id: String let symbol: String let colors: [Color] }
private let friends = [ Friend(id: "Ana", symbol: "person.fill", colors: [.pink, .orange]), Friend(id: "Leo", symbol: "pawprint.fill", colors: [.teal, .blue]), Friend(id: "Mia", symbol: "camera.fill", colors: [.purple, .indigo]), Friend(id: "Noa", symbol: "music.note", colors: [.green, .mint]), ]
@State private var seen: Set<String> = ["Noa"]
var body: some View { VStack(spacing: 40) { Spacer()
ScrollView(.horizontal) { HStack(spacing: 18) { ForEach(friends) { friend in Button { if seen.remove(friend.id) == nil { seen.insert(friend.id) } } label: { VStack(spacing: 8) { avatar(for: friend, size: 74) Text(friend.id) .font(.caption.weight(.medium)) .foregroundStyle(.primary) } } .buttonStyle(.plain) .accessibilityLabel(friend.id) .accessibilityValue(seen.contains(friend.id) ? "No new stories" : "New stories") } } .padding(.horizontal, 24) .frame(maxWidth: .infinity) } .scrollIndicators(.hidden) .fixedSize(horizontal: false, vertical: true)
avatar(for: friends[0], size: 190)
Text("Tap a friend to mark their stories as seen.") .font(.body) .foregroundStyle(.secondary) .multilineTextAlignment(.center) .padding(.horizontal, 32)
Spacer() } }
private func avatar(for friend: Friend, size: CGFloat) -> some View { StoryRingAvatar(name: friend.id, hasUnseenStories: !seen.contains(friend.id), size: size) { ZStack { LinearGradient(colors: friend.colors, startPoint: .topLeading, endPoint: .bottomTrailing) Image(systemName: friend.symbol) .font(.system(size: size * 0.38)) .foregroundStyle(.white) } } }}
#Preview { StoryRingAvatarDemo()}