How to implement OTP input in SwiftUI
Store each OTP digit in a String array, give every TextField a .focused(_:equals:) modifier keyed on its index, then use onChange to advance focus after each character is typed.
struct OTPInputView: View {
@State private var digits = Array(repeating: "", count: 6)
@FocusState private var focusedIndex: Int?
var body: some View {
HStack(spacing: 12) {
ForEach(0..<6, id: \.self) { i in
TextField("", text: $digits[i])
.keyboardType(.numberPad)
.focused($focusedIndex, equals: i)
.onChange(of: digits[i]) { _, new in
if new.count == 1 { focusedIndex = i + 1 }
else if new.isEmpty, i > 0 { focusedIndex = i - 1 }
}
.frame(width: 44, height: 54)
.multilineTextAlignment(.center)
.background(RoundedRectangle(cornerRadius: 10)
.stroke(focusedIndex == i ? .blue : .gray.opacity(0.4)))
}
}
}
}
Full implementation
The complete component below adds paste support, caps each field at one digit, moves focus backward on delete, fires a completion callback when all six slots are filled, and includes a clear button for good measure. The OTPDigitField sub-view isolates the per-cell styling so the parent stays readable.
import SwiftUI
// MARK: - Single digit cell
private struct OTPDigitField: View {
let index: Int
@Binding var digit: String
@FocusState.Binding var focusedIndex: Int?
let onAdvance: (Int) -> Void
let onRetreat: (Int) -> Void
var isFocused: Bool { focusedIndex == index }
var body: some View {
TextField("", text: $digit)
.keyboardType(.numberPad)
.textContentType(.oneTimeCode) // enables SMS autofill
.focused($focusedIndex, equals: index)
.multilineTextAlignment(.center)
.font(.title2.monospacedDigit().bold())
.frame(width: 48, height: 58)
.background(
RoundedRectangle(cornerRadius: 12)
.fill(isFocused ? Color.blue.opacity(0.07) : Color(.systemGray6))
)
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(isFocused ? Color.blue : Color.clear, lineWidth: 2)
)
.onChange(of: digit) { _, newValue in
// Strip non-digits and cap at 1 character
let cleaned = newValue.filter(\.isNumber)
digit = cleaned.isEmpty ? "" : String(cleaned.prefix(1))
if !digit.isEmpty {
onAdvance(index)
}
}
.accessibilityLabel("Digit \(index + 1) of 6")
}
}
// MARK: - OTP container
struct OTPInputView: View {
var digitCount: Int = 6
var onComplete: (String) -> Void = { _ in }
@State private var digits: [String]
@FocusState private var focusedIndex: Int?
init(digitCount: Int = 6, onComplete: @escaping (String) -> Void = { _ in }) {
self.digitCount = digitCount
self.onComplete = onComplete
_digits = State(initialValue: Array(repeating: "", count: digitCount))
}
private var code: String { digits.joined() }
private var isComplete: Bool { code.count == digitCount }
var body: some View {
VStack(spacing: 24) {
HStack(spacing: 10) {
ForEach(0..<digitCount, id: \.self) { i in
OTPDigitField(
index: i,
digit: $digits[i],
focusedIndex: $focusedIndex,
onAdvance: { idx in
if idx + 1 < digitCount {
focusedIndex = idx + 1
} else {
focusedIndex = nil // dismiss keyboard on last digit
onComplete(code)
}
},
onRetreat: { idx in
if idx > 0 { focusedIndex = idx - 1 }
}
)
}
}
// Paste support: detect multi-char paste on first field
.onChange(of: digits[0]) { _, newValue in
guard newValue.count > 1 else { return }
let nums = newValue.filter(\.isNumber)
for (offset, char) in nums.prefix(digitCount).enumerated() {
digits[offset] = String(char)
}
let filled = min(nums.count, digitCount)
focusedIndex = filled < digitCount ? filled : nil
if filled == digitCount { onComplete(code) }
}
HStack {
if !code.isEmpty {
Button("Clear") {
digits = Array(repeating: "", count: digitCount)
focusedIndex = 0
}
.font(.subheadline)
.foregroundStyle(.secondary)
}
Spacer()
if isComplete {
Label("Complete", systemImage: "checkmark.circle.fill")
.font(.subheadline.bold())
.foregroundStyle(.green)
.transition(.scale.combined(with: .opacity))
}
}
.animation(.spring(duration: 0.25), value: isComplete)
}
.padding()
.onAppear { focusedIndex = 0 }
}
}
// MARK: - Preview
#Preview("OTP Input") {
VStack(spacing: 32) {
Text("Enter verification code")
.font(.headline)
OTPInputView(digitCount: 6) { code in
print("Completed code:", code)
}
}
.padding()
}
How it works
-
@FocusState private var focusedIndex: Int?— a single optional integer tracks which digit cell is active. EachTextFieldgets.focused($focusedIndex, equals: i), so setting the integer programmatically moves the cursor between boxes without any UIKit scaffolding. -
.textContentType(.oneTimeCode)— tells iOS this is an OTP field, enabling the QuickType bar to surface one-time codes from Messages automatically. This single modifier handles the entire SMS autofill flow for free. -
onChange(of: digit)on each cell — after stripping non-digit characters, if the result is non-empty theonAdvanceclosure fires and bumpsfocusedIndexby one, moving the cursor forward. An empty value after a delete firesonRetreatto go backward. -
Paste detection via
onChangeondigits[0]— when a user pastes a full code, iOS fills the firstTextFieldwith all characters at once. The guard detectscount > 1, splits the string across the array, and sets focus to the appropriate slot or dismisses the keyboard. -
onComplete(code)callback — fired when the last digit is entered (or the paste fills all slots), so the parent view can immediately trigger verification without a submit button, giving a snappy feel matching native iOS auth flows.
Variants
4-digit PIN style with secure input
struct PINInputView: View {
@State private var digits = Array(repeating: "", count: 4)
@FocusState private var focusedIndex: Int?
@State private var isSecure = true
var body: some View {
VStack(spacing: 20) {
HStack(spacing: 12) {
ForEach(0..<4, id: \.self) { i in
ZStack {
RoundedRectangle(cornerRadius: 10)
.fill(Color(.systemGray6))
.frame(width: 56, height: 64)
if isSecure && !digits[i].isEmpty {
Circle()
.fill(.primary)
.frame(width: 14, height: 14)
} else {
Text(digits[i].isEmpty ? "" : digits[i])
.font(.title.monospacedDigit().bold())
}
TextField("", text: $digits[i])
.keyboardType(.numberPad)
.focused($focusedIndex, equals: i)
.opacity(0.011) // nearly invisible but hittable
.onChange(of: digits[i]) { _, new in
let c = new.filter(\.isNumber)
digits[i] = String(c.prefix(1))
if !digits[i].isEmpty, i + 1 < 4 {
focusedIndex = i + 1
}
}
}
.onTapGesture { focusedIndex = i }
}
}
Toggle("Show digits", isOn: $isSecure.not) // extension below
.font(.subheadline)
.tint(.blue)
}
.onAppear { focusedIndex = 0 }
}
}
// Convenience binding negation
extension Binding where Value == Bool {
var not: Binding<Bool> {
Binding(get: { !wrappedValue }, set: { wrappedValue = !$0 })
}
}
Alphanumeric code (e.g. referral codes)
Change .keyboardType(.numberPad) to .keyboardType(.default) and update the filter from \.isNumber to \.isLetter, or remove the filter entirely for mixed codes. You may also want to apply .textInputAutocapitalization(.characters) so letters are forced uppercase as the user types, matching typical promo-code formats like APPLE-XK9F2.
Common pitfalls
-
iOS 16 @FocusState enum vs Int? —
@FocusStatewas introduced in iOS 15, but using anOptional<Int>as the state type requires Swift 5.7+ and behaves slightly differently on iOS 16 simulators. Always test on a real device; the keyboard sometimes doesn't re-appear after the state resets on simulators running iOS 16. -
Paste only fires on the first field — the multi-char paste detection listens to
digits[0]only. If you also want paste to work when focus is on a middle field, add the sameonChangeguard on each cell and offset the split by the current focused index, otherwise you'll drop the pasted characters silently. -
VoiceOver focus order — SwiftUI lays out
HStackchildren in reading order by default, but always verify with the Accessibility Inspector. Add.accessibilityLabel("Digit \(i + 1) of \(digitCount)")to each cell so VoiceOver announces the position clearly, and add.accessibilityValue(digits[i].isEmpty ? "empty" : digits[i])so the current value is read aloud after the label.
Prompt this with Claude Code
When using Soarias or Claude Code directly to implement this:
Implement otp input in SwiftUI for iOS 17+. Use TextField and @FocusState. Make it accessible (VoiceOver labels, accessibilityValue per cell). Add a #Preview with realistic sample data.
In Soarias's Build phase, drop this prompt into a feature file alongside your authentication screen so Claude Code wires the completion callback directly into your sign-in view model.
Related
FAQ
Does this work on iOS 16?
Yes — @FocusState and TextField with onChange work from iOS 15 onward. However, .textContentType(.oneTimeCode) for SMS autofill is best supported from iOS 15.4+, and the onChange(of:initial:_:) two-parameter closure syntax used here requires iOS 17. For iOS 16 targets, fall back to the single-parameter onChange: .onChange(of: digit) { newValue in … }.
How do I pre-fill the OTP from a deep link?
Pass the code string into the view and split it on onAppear: let chars = Array(code.filter(\.isNumber).prefix(digitCount)); for (i, c) in chars.enumerated() { digits[i] = String(c) }. Then set focusedIndex = nil if the code is already complete or focusedIndex = chars.count to place the cursor on the next empty slot.
What's the UIKit equivalent?
In UIKit you'd create an array of UITextFields, implement textField(_:shouldChangeCharactersIn:replacementString:) in UITextFieldDelegate to enforce the 1-character limit, and call becomeFirstResponder() / resignFirstResponder() manually on each field. The SwiftUI approach is considerably less boilerplate thanks to @FocusState binding eliminating the delegate entirely.
Last reviewed: 2026-05-11 by the Soarias team.