```html SwiftUI: How to Implement OTP Input (iOS 17+, 2026)

How to implement OTP input in SwiftUI

iOS 17+ Xcode 16+ Intermediate APIs: TextField, @FocusState Updated: May 11, 2026
TL;DR

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

  1. @FocusState private var focusedIndex: Int? — a single optional integer tracks which digit cell is active. Each TextField gets .focused($focusedIndex, equals: i), so setting the integer programmatically moves the cursor between boxes without any UIKit scaffolding.
  2. .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.
  3. onChange(of: digit) on each cell — after stripping non-digit characters, if the result is non-empty the onAdvance closure fires and bumps focusedIndex by one, moving the cursor forward. An empty value after a delete fires onRetreat to go backward.
  4. Paste detection via onChange on digits[0] — when a user pastes a full code, iOS fills the first TextField with all characters at once. The guard detects count > 1, splits the string across the array, and sets focus to the appropriate slot or dismisses the keyboard.
  5. 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

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.

```