Skip to content

Counter Stepper

Minus and plus buttons around a number that rolls as it changes and stops at its limits.

Counter Stepper, playing
Download .swift
Origin
Original work
Found by
stepper · counter · quantity · plus minus · numeric text · number animation · contador · cantidad · más y menos · incrementar · texto numérico · animación de números

Minus and plus buttons around a number that rolls as it changes.

With Reduce Motion on, the number changes without rolling.

struct CounterStepperDemo: View {
@State private var tickets = 3
@Environment(\.accessibilityReduceMotion) private var reduceMotion
private var total: Int { tickets * 24 }
var body: some View {
VStack(spacing: 32) {
VStack(spacing: 6) {
Text("Tickets")
.font(.largeTitle.bold())
Text(total, format: .currency(code: "EUR").precision(.fractionLength(0)))
.font(.title2.weight(.semibold))
.foregroundStyle(.secondary)
.contentTransition(reduceMotion ? .identity : .numericText(value: Double(total)))
}
.accessibilityElement(children: .combine)
CounterStepper(value: $tickets, range: 0...5, title: "Tickets")
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
ParameterTypeDefault
valueBinding<Int>Required
rangeClosedRange<Int>0...99
stepInt1
titleLocalizedStringResource"Value"
tintColor.indigo

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

CounterStepper.swift
// SPDX-License-Identifier: MIT
// Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors
import SwiftUI
enum CounterStepperValue {
/// The result of adding `step` to `value`, kept inside `range`.
static func stepped(_ value: Int, by step: Int, in range: ClosedRange<Int>) -> Int {
let (sum, overflowed) = value.addingReportingOverflow(step)
if overflowed {
return step > 0 ? range.upperBound : range.lowerBound
}
return min(max(sum, range.lowerBound), range.upperBound)
}
static func canStep(_ value: Int, by step: Int, in range: ClosedRange<Int>) -> Bool {
stepped(value, by: step, in: range) != value
}
}
/// Minus and plus buttons around a number that rolls as it changes.
///
/// With Reduce Motion on, the number changes without rolling.
struct CounterStepper: View {
@Binding var value: Int
var range: ClosedRange<Int> = 0...99
var step: Int = 1
var title: LocalizedStringResource = "Value"
var tint: Color = .indigo
@ScaledMetric(relativeTo: .title) private var buttonSize = 56.0
@ScaledMetric(relativeTo: .largeTitle) private var numberWidth = 76.0
@Environment(\.accessibilityReduceMotion) private var reduceMotion
var body: some View {
HStack(spacing: 6) {
stepButton(systemImage: "minus", label: "Decrease", delta: -step)
Text(value, format: .number)
.font(.system(.largeTitle, design: .rounded, weight: .bold))
.monospacedDigit()
.contentTransition(reduceMotion ? .identity : .numericText(value: Double(value)))
.frame(minWidth: numberWidth)
.accessibilityLabel(title)
.accessibilityValue(value.formatted())
stepButton(systemImage: "plus", label: "Increase", delta: step)
}
.padding(6)
.background(.quaternary, in: .capsule)
.sensoryFeedback(.selection, trigger: value)
}
private func stepButton(systemImage: String, label: String, delta: Int) -> some View {
let isEnabled = CounterStepperValue.canStep(value, by: delta, in: range)
return Button {
withAnimation(reduceMotion ? nil : .snappy) {
value = CounterStepperValue.stepped(value, by: delta, in: range)
}
} label: {
Image(systemName: systemImage)
.font(.title2.bold())
.foregroundStyle(isEnabled ? Color.white : Color.secondary)
.frame(width: buttonSize, height: buttonSize)
.background(isEnabled ? tint : Color.gray.opacity(0.2), in: .circle)
.contentShape(.circle)
}
.buttonStyle(.plain)
.disabled(!isEnabled)
.accessibilityLabel(label)
}
}
// MARK: - Demo
struct CounterStepperDemo: View {
@State private var tickets = 3
@Environment(\.accessibilityReduceMotion) private var reduceMotion
private var total: Int { tickets * 24 }
var body: some View {
VStack(spacing: 32) {
VStack(spacing: 6) {
Text("Tickets")
.font(.largeTitle.bold())
Text(total, format: .currency(code: "EUR").precision(.fractionLength(0)))
.font(.title2.weight(.semibold))
.foregroundStyle(.secondary)
.contentTransition(reduceMotion ? .identity : .numericText(value: Double(total)))
}
.accessibilityElement(children: .combine)
CounterStepper(value: $tickets, range: 0...5, title: "Tickets")
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
#Preview {
CounterStepperDemo()
}