Animated Badge Count
A count badge whose number rolls to its new value and whose capsule springs to fit it.

- Feedback
- iOS 18.0+
- beginner
- Origin
- Original work
- Found by
- badge · count · notification · number · rolling · unread · capsule · insignia · contador · notificación · número · rodillo · sin leer · cápsula
Description
Section titled “Description”A count badge whose number rolls to its new value while the capsule springs to fit.
It hides at zero. With Reduce Motion on, the number changes without moving.
struct AnimatedBadgeCountDemo: View { private static let script = [3, 4, 9, 12, 98, 99, 120, 0, 1, 2]
@State private var step = 0 @Environment(\.accessibilityReduceMotion) private var reduceMotion
private var count: Int { Self.script[step] }
var body: some View { ZStack { LinearGradient(colors: [.indigo, .blue], startPoint: .topLeading, endPoint: .bottomTrailing) .ignoresSafeArea()
HStack(spacing: 22) { icon("bell.fill", label: "Notifications", tint: .red) icon("envelope.fill", label: "Mail", tint: .orange) icon("cart.fill", label: "Cart", tint: .green) } .scaleEffect(1.4) } .task(id: reduceMotion) { guard !reduceMotion else { return } while !Task.isCancelled { try? await Task.sleep(for: .seconds(0.75)) guard !Task.isCancelled else { return } step = (step + 1) % Self.script.count } } }
private func icon(_ systemImage: String, label: String, tint: Color) -> some View { Image(systemName: systemImage) .font(.largeTitle) .foregroundStyle(.white) .padding(12) .accessibilityLabel(label) .overlay(alignment: .topTrailing) { AnimatedBadgeCount(count: count, tint: tint) .offset(x: 12, y: -6) } }}API reference
Section titled “API reference”| Parameter | Type | Default |
|---|---|---|
count | Int | Required |
maximum | Int | 99 |
tint | Color | .red |
unit | String | "new" |
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
/// A count badge whose number rolls to its new value while the capsule springs to fit.////// It hides at zero. With Reduce Motion on, the number changes without moving.struct AnimatedBadgeCount: View { var count: Int var maximum = 99 var tint: Color = .red var unit = "new"
@ScaledMetric(relativeTo: .caption) private var height = 22.0 @Environment(\.accessibilityReduceMotion) private var reduceMotion
private var text: String? { AnimatedBadgeCountFormat.text(count: count, maximum: maximum) }
var body: some View { ZStack { if let text { Text(text) .font(.caption.weight(.bold)) .monospacedDigit() .foregroundStyle(AnimatedBadgeCountContrastingForeground(background: tint)) .contentTransition(reduceMotion ? .identity : .numericText(value: Double(count))) .padding(.horizontal, height * 0.32) .frame(minWidth: height, minHeight: height) .background(tint, in: .capsule) .transition(reduceMotion ? .opacity : .scale.combined(with: .opacity)) .accessibilityLabel("\(text) \(unit)") } } .animation(reduceMotion ? nil : .spring(duration: 0.4, bounce: 0.45), value: text) }}
enum AnimatedBadgeCountFormat { /// The text of the badge, or `nil` when there is nothing to count. static func text(count: Int, maximum: Int) -> String? { guard count > 0 else { return nil } let maximum = max(maximum, 1) return count > maximum ? "\(maximum)+" : "\(count)" }}
/// White or black, whichever reads better on `background`. Black only on a/// light color, so the system's own colors keep white.nonisolated struct AnimatedBadgeCountContrastingForeground: ShapeStyle { let background: Color
func resolve(in environment: EnvironmentValues) -> Color { let color = background.resolve(in: environment) let luminance = 0.2126 * color.linearRed + 0.7152 * color.linearGreen + 0.0722 * color.linearBlue return luminance > 0.5 ? .black : .white }}
// MARK: - Demo
struct AnimatedBadgeCountDemo: View { private static let script = [3, 4, 9, 12, 98, 99, 120, 0, 1, 2]
@State private var step = 0 @Environment(\.accessibilityReduceMotion) private var reduceMotion
private var count: Int { Self.script[step] }
var body: some View { ZStack { LinearGradient(colors: [.indigo, .blue], startPoint: .topLeading, endPoint: .bottomTrailing) .ignoresSafeArea()
HStack(spacing: 22) { icon("bell.fill", label: "Notifications", tint: .red) icon("envelope.fill", label: "Mail", tint: .orange) icon("cart.fill", label: "Cart", tint: .green) } .scaleEffect(1.4) } .task(id: reduceMotion) { guard !reduceMotion else { return } while !Task.isCancelled { try? await Task.sleep(for: .seconds(0.75)) guard !Task.isCancelled else { return } step = (step + 1) % Self.script.count } } }
private func icon(_ systemImage: String, label: String, tint: Color) -> some View { Image(systemName: systemImage) .font(.largeTitle) .foregroundStyle(.white) .padding(12) .accessibilityLabel(label) .overlay(alignment: .topTrailing) { AnimatedBadgeCount(count: count, tint: tint) .offset(x: 12, y: -6) } }}
#Preview { AnimatedBadgeCountDemo()}