Skip to content

Animated Badge Count

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

Animated Badge Count, playing
Download .swift
Origin
Original work
Found by
badge · count · notification · number · rolling · unread · capsule · insignia · contador · notificación · número · rodillo · sin leer · cápsula

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)
}
}
}
ParameterTypeDefault
countIntRequired
maximumInt99
tintColor.red
unitString"new"

Copy this code into your project, or download the file.

AnimatedBadgeCount.swift
// 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()
}