Skip to content

Add To Cart Fly Button

An add-to-cart button that sends a copy of the product image arcing into the cart badge.

Add To Cart Fly Button, playing
Download .swift
Origin
Original work
Found by
add to cart · shopping · ecommerce · fly animation · cart badge · arc · product · añadir al carrito · compras · tienda · animación de vuelo · insignia del carrito · producto

A product image and an add button that sends a copy of the image arcing into the cart.

Measure your cart icon in the .global space and pass its frame as cartFrame. The count goes up when the copy lands. With Reduce Motion on, or with no cartFrame, the count goes up at once and nothing flies.

struct AddToCartFlyButtonDemo: View {
@State private var count = 0
@State private var cartFrame: CGRect?
var body: some View {
VStack(spacing: 0) {
HStack {
Text("Shop")
.font(.largeTitle.bold())
Spacer()
AddToCartFlyButtonCart(count: count)
.onGeometryChange(for: CGRect.self) { $0.frame(in: .global) } action: { cartFrame = $0 }
}
.padding(.horizontal, 24)
.padding(.top, 8)
ScrollView {
VStack(alignment: .leading, spacing: 8) {
AddToCartFlyButton(
image: Image(systemName: "backpack.fill"),
count: $count,
cartFrame: cartFrame,
imageLabel: "Trail backpack"
)
.padding(.top, 48)
Text("Trail backpack")
.font(.title2.bold())
.padding(.top, 12)
Text("Twenty liters, water resistant and light enough for every day.")
.foregroundStyle(.secondary)
}
.padding(24)
}
.scrollClipDisabled()
}
}
}
ParameterTypeDefault
imageImageRequired
countBinding<Int>Required
cartFrameCGRect?nil
titleLocalizedStringResource"Add to Cart"
imageLabelLocalizedStringResource"Product"
tintColor.indigo
onAdd() -> Void{}

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

AddToCartFlyButton.swift
// SPDX-License-Identifier: MIT
// Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors
import SwiftUI
/// A product image and an add button that sends a copy of the image arcing into the cart.
///
/// Measure your cart icon in the `.global` space and pass its frame as `cartFrame`. The
/// count goes up when the copy lands. With Reduce Motion on, or with no `cartFrame`, the
/// count goes up at once and nothing flies.
struct AddToCartFlyButton: View {
let image: Image
@Binding var count: Int
var cartFrame: CGRect?
var title: LocalizedStringResource = "Add to Cart"
var imageLabel: LocalizedStringResource = "Product"
var tint: Color = .indigo
var onAdd: () -> Void = {}
@State private var flights: [AddToCartFlyButtonLaunch] = []
@State private var sourceFrame = CGRect.zero
@Environment(\.accessibilityReduceMotion) private var reduceMotion
private let maximumFlights = 6
var body: some View {
VStack(spacing: 20) {
image
.resizable()
.scaledToFit()
.foregroundStyle(tint)
.padding(36)
.frame(maxWidth: .infinity)
.frame(height: 260)
.background(tint.opacity(0.12), in: .rect(cornerRadius: 28))
.accessibilityLabel(imageLabel)
.onGeometryChange(for: CGRect.self) { $0.frame(in: .global) } action: { sourceFrame = $0 }
.overlay {
ForEach(flights) { flight in
AddToCartFlyButtonFlight(image: image, destination: flight.destination, tint: tint) {
land(flight.id)
}
}
}
Button(action: add) {
Label(title, systemImage: "cart.badge.plus")
.font(.headline)
.frame(maxWidth: .infinity, minHeight: 44)
}
.buttonStyle(.borderedProminent)
.tint(tint)
}
}
private func add() {
guard !reduceMotion, flights.count < maximumFlights, let cartFrame, sourceFrame != .zero else {
increment()
return
}
let destination = CGSize(width: cartFrame.midX - sourceFrame.midX, height: cartFrame.midY - sourceFrame.midY)
flights.append(AddToCartFlyButtonLaunch(destination: destination))
}
private func land(_ id: UUID) {
guard let index = flights.firstIndex(where: { $0.id == id }) else { return }
flights.remove(at: index)
increment()
}
private func increment() {
count = count == .max ? count : count + 1
onAdd()
}
}
private struct AddToCartFlyButtonLaunch: Identifiable {
let id = UUID()
let destination: CGSize
}
/// The flying copy. It lands even if its view goes away mid-flight, so no item is lost.
private struct AddToCartFlyButtonFlight: View {
let image: Image
let destination: CGSize
let tint: Color
let onLand: () -> Void
@State private var progress = 0.0
private let duration = 0.65
var body: some View {
image
.resizable()
.scaledToFit()
.foregroundStyle(tint)
.padding(14)
.frame(width: 84, height: 84)
.background(.background, in: .circle)
.overlay { Circle().strokeBorder(tint.opacity(0.3)) }
.modifier(AddToCartFlyButtonArc(progress: progress, destination: destination))
.allowsHitTesting(false)
.accessibilityHidden(true)
.task {
withAnimation(.easeIn(duration: duration)) { progress = 1 }
try? await Task.sleep(for: .seconds(duration))
onLand()
}
}
}
/// Moves a view along the arc and shrinks it as it travels.
private struct AddToCartFlyButtonArc: GeometryEffect {
var progress: Double
let destination: CGSize
var animatableData: Double {
get { progress }
set { progress = newValue }
}
func effectValue(size: CGSize) -> ProjectionTransform {
let offset = AddToCartFlyButtonLogic.arcOffset(progress: progress, destination: destination, lift: 120)
let scale = 1 - 0.75 * CGFloat(min(max(progress, 0), 1))
let transform = CGAffineTransform(translationX: offset.width + size.width / 2, y: offset.height + size.height / 2)
.scaledBy(x: scale, y: scale)
.translatedBy(x: -size.width / 2, y: -size.height / 2)
return ProjectionTransform(transform)
}
}
enum AddToCartFlyButtonLogic {
/// A point on a curve from zero to `destination` that first rises by about `lift`.
static func arcOffset(progress: Double, destination: CGSize, lift: CGFloat) -> CGSize {
let t = CGFloat(min(max(progress, 0), 1))
let controlX = destination.width * 0.35
let controlY = min(destination.height, 0) - lift
return CGSize(
width: 2 * (1 - t) * t * controlX + t * t * destination.width,
height: 2 * (1 - t) * t * controlY + t * t * destination.height
)
}
}
/// A cart icon with a count badge that bumps when the count changes.
struct AddToCartFlyButtonCart: View {
let count: Int
var tint: Color = .indigo
@Environment(\.accessibilityReduceMotion) private var reduceMotion
var body: some View {
Image(systemName: "cart.fill")
.font(.title2)
.foregroundStyle(tint)
.padding(8)
.overlay(alignment: .topTrailing) {
if count > 0 {
Text(count, format: .number)
.font(.caption2.weight(.bold))
.foregroundStyle(.white)
.contentTransition(reduceMotion ? .identity : .numericText(value: Double(count)))
.animation(reduceMotion ? nil : .snappy, value: count)
.padding(.horizontal, 5)
.frame(minWidth: 18, minHeight: 18)
.background(.red, in: .capsule)
}
}
.keyframeAnimator(initialValue: 1.0, trigger: reduceMotion ? 0 : count) { [reduceMotion] view, scale in
view.scaleEffect(reduceMotion ? 1 : scale)
} keyframes: { _ in
SpringKeyframe(1.3, duration: 0.14)
SpringKeyframe(1, duration: 0.3)
}
.accessibilityElement(children: .ignore)
.accessibilityLabel("Cart")
.accessibilityValue(count == 1 ? "1 item" : "\(count) items")
}
}
// MARK: - Demo
struct AddToCartFlyButtonDemo: View {
@State private var count = 0
@State private var cartFrame: CGRect?
var body: some View {
VStack(spacing: 0) {
HStack {
Text("Shop")
.font(.largeTitle.bold())
Spacer()
AddToCartFlyButtonCart(count: count)
.onGeometryChange(for: CGRect.self) { $0.frame(in: .global) } action: { cartFrame = $0 }
}
.padding(.horizontal, 24)
.padding(.top, 8)
ScrollView {
VStack(alignment: .leading, spacing: 8) {
AddToCartFlyButton(
image: Image(systemName: "backpack.fill"),
count: $count,
cartFrame: cartFrame,
imageLabel: "Trail backpack"
)
.padding(.top, 48)
Text("Trail backpack")
.font(.title2.bold())
.padding(.top, 12)
Text("Twenty liters, water resistant and light enough for every day.")
.foregroundStyle(.secondary)
}
.padding(24)
}
.scrollClipDisabled()
}
}
}
#Preview {
AddToCartFlyButtonDemo()
}