How to Build a Stepper Input in SwiftUI
Bind an @State integer or double to
Stepper, then set the
in: range and
step: to control increments.
SwiftUI handles the +/− buttons and clamping automatically.
struct QuickStepperView: View {
@State private var quantity = 1
var body: some View {
Stepper("Quantity: \(quantity)",
value: $quantity,
in: 1...99,
step: 1)
.padding()
}
}
#Preview {
QuickStepperView()
}
Full implementation
The example below wires a Stepper into a small
shopping-cart quantity picker. It clamps the value to a sensible range, displays the current count
inside the label closure for full control over formatting, and adds an accessible hint so VoiceOver
announces the purpose of the control. A Form
wrapper gives the stepper the standard inset-grouped appearance familiar to iOS users.
import SwiftUI
struct QuantityPickerView: View {
// MARK: – State
@State private var quantity: Int = 1
private let range = 1...50
private let step = 1
private let product = "Espresso Beans (250 g)"
// MARK: – Body
var body: some View {
NavigationStack {
Form {
Section("Order details") {
Stepper(value: $quantity, in: range, step: step) {
HStack {
Text("Quantity")
.foregroundStyle(.primary)
Spacer()
Text("\(quantity)")
.foregroundStyle(.secondary)
.monospacedDigit()
.contentTransition(.numericText())
.animation(.snappy, value: quantity)
}
}
.accessibilityLabel("Quantity")
.accessibilityValue("\(quantity)")
.accessibilityHint("Adjust the number of items to order")
}
Section {
LabeledContent("Item", value: product)
LabeledContent("Each", value: "$14.99")
LabeledContent("Total", value: totalFormatted)
.bold()
}
}
.navigationTitle("Add to Cart")
.navigationBarTitleDisplayMode(.inline)
}
}
// MARK: – Helpers
private var totalFormatted: String {
let total = Double(quantity) * 14.99
return total.formatted(.currency(code: "USD"))
}
}
#Preview {
QuantityPickerView()
}
How it works
-
Binding with
$quantity— Passing the binding toStepper(value:)means SwiftUI reads the current value and writes increments/decrements automatically. You never touch the +/− logic yourself. -
in: range, step: step— Thein:parameter is aClosedRange<Int>that clamps the value so it never goes below 1 or above 50. SwiftUI greys out the appropriate button at the boundary automatically. -
Label closure for custom layout — Using the trailing label closure instead of a plain
string lets us place an
HStackwith the count on the right, styled as secondary text with.monospacedDigit()so the layout doesn't jump as digits change. -
.contentTransition(.numericText())— Added to the countText, this iOS 17+ modifier animates digit changes with a rolling-number effect, giving the control a polished feel without any extra state. -
Accessibility modifiers —
.accessibilityLabel,.accessibilityValue, and.accessibilityHintgive VoiceOver users a clear, spoken description of what the stepper does and its current value, meeting WCAG 2.1 AA requirements without duplicating visible text.
Variants
Floating-point steps (e.g. temperature in 0.5° increments)
struct TemperatureStepperView: View {
@State private var temperature: Double = 20.0
var body: some View {
Form {
Stepper(value: $temperature,
in: 15.0...30.0,
step: 0.5) {
LabeledContent("Temperature",
value: temperature,
format: .number.precision(.fractionLength(1))
.notation(.automatic))
}
}
.navigationTitle("Thermostat")
}
}
#Preview { TemperatureStepperView() }
Callback-based stepper (onIncrement / onDecrement)
When you need side effects on each tap — such as haptic feedback or an async network call — use the
Stepper(label:onIncrement:onDecrement:)
initialiser instead. Manage the value yourself inside each closure and call
UIImpactFeedbackGenerator(style: .light).impactOccurred()
to add tactile feedback. Note that clamping is then your responsibility — add guard statements at the
top of each closure to stay within your intended range.
Common pitfalls
-
iOS version for
.contentTransition(.numericText()): This modifier requires iOS 16+, but the animated variant (.numericText(countsDown:)) appeared in iOS 17. Gate with#if swift(>=5.9)or an@availablecheck if you still need an iOS 16 fallback. -
Missing clamping with callback initialiser: If you switch to
onIncrement:/onDecrement:, SwiftUI does not clamp for you. Forgetting a range check means users can tap past your intended limits, causing silent data corruption or out-of-bounds crashes downstream. -
Accessibility double-announcement: If you put a numeric
Textinside the label closure and also set.accessibilityValue, VoiceOver may read the number twice. Either rely on the default behaviour (no explicit.accessibilityValue) or hide the label text from accessibility with.accessibilityHidden(true)so only the explicit value is spoken.
Prompt this with Claude Code
When using Soarias or Claude Code directly to implement this:
Implement a stepper input in SwiftUI for iOS 17+. Use Stepper with a label closure, in: range, and step: parameters. Make it accessible (VoiceOver labels, accessibilityValue, accessibilityHint). Add a #Preview with realistic sample data.
In Soarias's Build phase, paste this prompt into the active session — Claude Code will scaffold the component, wire it to your SwiftData model, and drop it straight into the right file without leaving your editor.
Related
FAQ
Does this work on iOS 16?
Yes — Stepper itself has been available
since SwiftUI's debut on iOS 13. The one iOS 17-specific feature in the full example is
.contentTransition(.numericText()) with an
animation; wrap it in if #available(iOS 17, *)
and the rest of the view compiles and runs perfectly on iOS 16.
How do I prevent the stepper from going below zero without a range?
Prefer the in: parameter — it's the
declarative, safe choice. If you're using the callback form, add
guard quantity > 0 else { return }
at the top of onDecrement. Avoid clamping
after mutation because it causes a brief flash of the out-of-range value before SwiftUI
re-renders.
What is the UIKit equivalent?
UIStepper is the direct counterpart. Set
minimumValue,
maximumValue, and
stepValue on it, then observe value changes
via a valueChanged action target. In SwiftUI
you get all of this in a single Stepper(...)
call with no target/action boilerplate.
Last reviewed: 2026-05-11 by the Soarias team.