Skip to content

Add To Cart Stepper

An add button that morphs into a quantity stepper and collapses back to the button when the quantity returns to zero.

Add To Cart Stepper, playing
Download .swift
Origin
Original work
Found by
add to cart · stepper · quantity · morph · ecommerce · shopping button · añadir al carrito · selector de cantidad · cantidad · transformación · tienda · botón de compra

An add button that morphs into a quantity stepper, and back again at zero.

The capsule is one background around content that changes, so it resizes by itself and nothing has to match geometry. VoiceOver can also adjust the count directly.

struct AddToCartStepperDemo: View {
@State private var quantity = 0
private var subtotal: String {
(Double(quantity) * 3.5).formatted(.currency(code: "EUR"))
}
var body: some View {
ViewThatFits(in: .vertical) {
content
ScrollView { content }
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(LinearGradient(colors: [.orange.opacity(0.16), .indigo.opacity(0.12)], startPoint: .top, endPoint: .bottom))
}
private var content: some View {
VStack(spacing: 24) {
Image(systemName: "cup.and.saucer.fill")
.resizable()
.scaledToFit()
.frame(height: 110)
.foregroundStyle(.linearGradient(colors: [.orange, .pink], startPoint: .top, endPoint: .bottom))
.accessibilityHidden(true)
VStack(spacing: 4) {
Text("Flat white")
.font(.largeTitle.bold())
Text("Double shot, velvety milk")
.foregroundStyle(.secondary)
}
AddToCartStepper(quantity: $quantity)
.frame(minHeight: 44)
Text("Subtotal \(subtotal)")
.font(.title3.weight(.semibold))
.opacity(quantity > 0 ? 1 : 0)
}
.padding(24)
}
}
ParameterTypeDefault
quantityBinding<Int>Required
maximumInt99
titleLocalizedStringResource"Add"
tintColor.indigo

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

AddToCartStepper.swift
// SPDX-License-Identifier: MIT
// Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors
import SwiftUI
enum AddToCartStepperMath {
/// The highest quantity allowed. Zero is valid, and means nothing can be added.
static func limit(maximum: Int) -> Int {
min(max(maximum, 0), 1_000_000)
}
}
/// An add button that morphs into a quantity stepper, and back again at zero.
///
/// The capsule is one background around content that changes, so it resizes by itself
/// and nothing has to match geometry. VoiceOver can also adjust the count directly.
struct AddToCartStepper: View {
@Binding var quantity: Int
var maximum: Int = 99
var title: LocalizedStringResource = "Add"
var tint: Color = .indigo
@ScaledMetric(relativeTo: .headline) private var buttonSize = 44.0
@ScaledMetric(relativeTo: .headline) private var countWidth = 34.0
@Environment(\.accessibilityReduceMotion) private var reduceMotion
private var limit: Int { AddToCartStepperMath.limit(maximum: maximum) }
private var current: Int { min(max(quantity, 0), limit) }
private var animation: Animation? { reduceMotion ? nil : .bouncy(duration: 0.35) }
var body: some View {
HStack(spacing: 0) {
if quantity <= 0 {
addButton.transition(.opacity)
} else {
stepButton(systemImage: quantity == 1 ? "trash" : "minus", label: "Decrease quantity", change: -1)
.transition(.opacity)
countLabel.transition(.opacity)
stepButton(systemImage: "plus", label: "Increase quantity", change: 1)
.disabled(quantity >= limit)
.transition(.opacity)
}
}
.foregroundStyle(AddToCartStepperContrastingForeground(background: tint))
.background(tint.gradient, in: .capsule)
.sensoryFeedback(.selection, trigger: quantity)
}
private var addButton: some View {
Button {
set(1)
} label: {
Label(title, systemImage: "plus")
.font(.headline)
.padding(.horizontal, 22)
.frame(minHeight: buttonSize)
.contentShape(.capsule)
}
.buttonStyle(.plain)
.disabled(limit == 0)
.accessibilityLabel("\(title) to cart")
}
private func stepButton(systemImage: String, label: String, change: Int) -> some View {
Button {
set(current + change)
} label: {
Image(systemName: systemImage)
.font(.headline)
.frame(width: buttonSize, height: buttonSize)
.contentTransition(reduceMotion ? .identity : .symbolEffect(.replace))
.contentShape(.rect)
}
.buttonStyle(.plain)
.accessibilityLabel(label)
}
private var countLabel: some View {
Text(quantity, format: .number)
.font(.headline.monospacedDigit())
.lineLimit(1)
.minimumScaleFactor(0.6)
.contentTransition(reduceMotion ? .identity : .numericText(value: Double(quantity)))
.frame(minWidth: countWidth)
.accessibilityLabel("Quantity")
.accessibilityValue(quantity.formatted())
.accessibilityAdjustableAction { direction in
switch direction {
case .increment: set(current + 1)
case .decrement: set(current - 1)
@unknown default: break
}
}
}
private func set(_ newValue: Int) {
withAnimation(animation) { quantity = min(max(newValue, 0), limit) }
}
}
/// White or black, whichever reads better on `background`. Black only on a
/// light color, so the system's own colors keep white.
nonisolated struct AddToCartStepperContrastingForeground: 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 AddToCartStepperDemo: View {
@State private var quantity = 0
private var subtotal: String {
(Double(quantity) * 3.5).formatted(.currency(code: "EUR"))
}
var body: some View {
ViewThatFits(in: .vertical) {
content
ScrollView { content }
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(LinearGradient(colors: [.orange.opacity(0.16), .indigo.opacity(0.12)], startPoint: .top, endPoint: .bottom))
}
private var content: some View {
VStack(spacing: 24) {
Image(systemName: "cup.and.saucer.fill")
.resizable()
.scaledToFit()
.frame(height: 110)
.foregroundStyle(.linearGradient(colors: [.orange, .pink], startPoint: .top, endPoint: .bottom))
.accessibilityHidden(true)
VStack(spacing: 4) {
Text("Flat white")
.font(.largeTitle.bold())
Text("Double shot, velvety milk")
.foregroundStyle(.secondary)
}
AddToCartStepper(quantity: $quantity)
.frame(minHeight: 44)
Text("Subtotal \(subtotal)")
.font(.title3.weight(.semibold))
.opacity(quantity > 0 ? 1 : 0)
}
.padding(24)
}
}
#Preview {
AddToCartStepperDemo()
}