SwiftUI OTP Field Component

One-time-code entry. A real hidden text field drives visible boxes, which is the only approach that keeps iOS SMS autofill working — per-box TextFields look right and then silently break the one feature that matters.

Enter the code we sent you

Install the free SwiftUI otp field

  1. Add SwiftKitUI.swift to your app once. It holds the tokens and motion curves.
  2. Copy SKOTPField.swift below into your project. It needs nothing else.

Usage

Usage.swift
SKOTPField(code: $code) { verify($0) }

Full source · 147 lines · MIT

SKOTPField.swift
//
//  SKOTPField.swift
//  Swift Kit UI
//
//  Requires: SwiftKitUI.swift
//
//  One-time-code entry. A real hidden text field drives visible boxes, which is
//  the only approach that keeps iOS SMS autofill working — per-box TextFields
//  look right and then silently break the one feature that matters.
//
//  Usage:
//      SKOTPField(code: $code) { verify($0) }
//

import SwiftUI

public struct SKOTPField: View {
    @Binding private var code: String
    private let length: Int
    private let isInvalid: Bool
    private let onComplete: ((String) -> Void)?

    @FocusState private var focused: Bool
    @Environment(\.accessibilityReduceMotion) private var reduceMotion

    /// - Parameter isInvalid: flip to `true` after a rejected code to shake the
    ///   boxes. Driven by state rather than by calling a method on the view —
    ///   a `View` is a value type, so an imperative `shake()` on it can never
    ///   reach the live instance on screen.
    public init(
        code: Binding<String>,
        length: Int = 6,
        isInvalid: Bool = false,
        onComplete: ((String) -> Void)? = nil
    ) {
        self._code = code
        self.length = length
        self.isInvalid = isInvalid
        self.onComplete = onComplete
    }

    public var body: some View {
        ZStack {
            hiddenInput
            boxes
        }
        .contentShape(.rect)
        .onTapGesture { focused = true }
        .keyframeAnimator(initialValue: CGFloat.zero, trigger: isInvalid) { view, offset in
            view.offset(x: offset)
        } keyframes: { _ in
            // Decaying oscillation. Skipped entirely under Reduce Motion by
            // collapsing to a single zero keyframe.
            if reduceMotion {
                CubicKeyframe(0, duration: 0.01)
            } else {
                CubicKeyframe(-10, duration: 0.06)
                CubicKeyframe(10, duration: 0.08)
                CubicKeyframe(-6, duration: 0.08)
                CubicKeyframe(4, duration: 0.07)
                CubicKeyframe(0, duration: 0.06)
            }
        }
        .onChange(of: isInvalid) { _, invalid in
            if invalid { SK.Haptic.play(.error) }
        }
        .accessibilityElement()
        .accessibilityLabel("Verification code")
        .accessibilityValue(code.isEmpty ? "Empty" : code.map(String.init).joined(separator: " "))
    }

    /// Invisible but still a real, focusable field — `.opacity(0)` alone would
    /// make it unfocusable on some iOS versions, hence the tiny frame.
    private var hiddenInput: some View {
        TextField("", text: $code)
            .skKeyboard(.number)
            .skOneTimeCode()
            .focused($focused)
            .frame(width: 1, height: 1)
            .opacity(0.001)
            .onChange(of: code) { _, new in
                let digits = String(new.filter(\.isNumber).prefix(length))
                if digits != new { code = digits }
                if digits.count == length {
                    focused = false
                    SK.Haptic.play(.success)
                    onComplete?(digits)
                } else if !digits.isEmpty {
                    SK.Haptic.play(.light)
                }
            }
    }

    private var boxes: some View {
        HStack(spacing: SK.Space.sm) {
            ForEach(0..<length, id: \.self) { index in
                box(at: index)
            }
        }
    }

    private func box(at index: Int) -> some View {
        let characters = Array(code)
        let filled = index < characters.count
        let isNext = index == characters.count && focused

        return Text(filled ? String(characters[index]) : "")
            .font(SK.Type_.numeric)
            .foregroundStyle(SK.Palette.label)
            .frame(width: 46, height: 56)
            .background(SK.Palette.surface, in: RoundedRectangle(cornerRadius: SK.Radius.md, style: .continuous))
            .overlay {
                RoundedRectangle(cornerRadius: SK.Radius.md, style: .continuous)
                    .strokeBorder(
                        isInvalid ? SK.Palette.danger
                            : (isNext ? SK.Palette.accent : SK.Palette.separator),
                        lineWidth: isInvalid || isNext ? SK.Stroke.thick : SK.Stroke.thin
                    )
            }
            .scaleEffect(isNext ? 1.04 : 1)
            .skAnimation(.snap, value: code)
            .skAnimation(.snap, value: focused)
    }

}

#Preview("SKOTPField") {
    @Previewable @State var code = ""
    @Previewable @State var invalid = false

    VStack(spacing: SK.Space.xl) {
        Text("Enter the 6-digit code")
            .font(SK.Type_.headline)
            .foregroundStyle(SK.Palette.label)
        SKOTPField(code: $code, isInvalid: invalid) { entered in
            // Anything but 123456 is rejected, so the shake is demonstrable.
            invalid = entered != "123456"
        }
        Text(invalid ? "That code is not right." : "Try 123456")
            .font(SK.Type_.caption)
            .foregroundStyle(invalid ? SK.Palette.danger : SK.Palette.labelTertiary)
    }
    .padding(SK.Space.xl)
    .frame(maxWidth: .infinity, maxHeight: .infinity)
    .background(SK.Palette.surfaceSunken)
}

Questions

Is this SwiftUI otp field free for commercial apps?
Yes. Swift Kit UI is MIT licensed, so you can use it in personal and commercial apps with no payment or sign-up. Keep the licence notice with the code.
What does it depend on?
SKOTPField.swift depends only on SwiftKitUI.swift, a single foundation file with the design tokens and motion curves. There are no third-party packages.
Which iOS versions does it support?
iOS 17 and later, plus macOS 14.

Related components

Building a whole app? The Swift Kit adds auth, payments and onboarding, ready to ship.