```html SwiftUI: How to Build Phone Number Input (iOS 17+, 2026)

How to Build a Phone Number Input in SwiftUI

iOS 17+ Xcode 16+ Intermediate APIs: TextField / Formatter Updated: May 12, 2026
TL;DR

Store raw digits in a @State string, format them into (XXX) XXX-XXXX on every keystroke via an onChange modifier, and bind a display string to TextField with .keyboardType(.phonePad).

struct PhoneField: View {
    @State private var digits = ""
    @State private var display = ""

    var body: some View {
        TextField("(555) 000-0000", text: $display)
            .keyboardType(.phonePad)
            .onChange(of: display) { _, new in
                let raw = new.filter(\.isNumber)
                digits  = String(raw.prefix(10))
                display = formatted(digits)
            }
    }

    func formatted(_ d: String) -> String {
        var s = d
        if s.count > 6 { s.insert("-", at: s.index(s.startIndex, offsetBy: 6)) }
        if s.count > 3 { s.insert(") ", at: s.index(s.startIndex, offsetBy: 3)) }
        if !s.isEmpty  { s.insert("(", at: s.startIndex) }
        return s
    }
}

Full implementation

The cleanest approach keeps two separate pieces of state: raw digits (used for validation and submission) and a display string (shown to the user with punctuation). The onChange(of:) modifier — using the two-closure form required in iOS 17 — strips non-digits, caps the length at 10, and rebuilds the formatted string every time the user types. A small isValid computed property enables or disables the submit button based on digit count, and an optional country-code prefix keeps the field composable inside larger forms.

import SwiftUI

// MARK: – Formatter helper
private func formatPhone(_ digits: String) -> String {
    let d = String(digits.prefix(10))
    var result = ""
    for (i, ch) in d.enumerated() {
        switch i {
        case 0: result += "(\(ch)"
        case 3: result += ") \(ch)"
        case 6: result += "-\(ch)"
        default: result += "\(ch)"
        }
    }
    return result
}

// MARK: – View
struct PhoneNumberInputView: View {
    /// Callback receives the raw 10-digit string (e.g. "5551234567")
    var onCommit: (String) -> Void = { _ in }

    @State private var rawDigits  = ""
    @State private var display    = ""
    @State private var isInvalid  = false
    @FocusState private var focused: Bool

    private var isValid: Bool { rawDigits.count == 10 }

    var body: some View {
        VStack(alignment: .leading, spacing: 6) {
            Text("Phone number")
                .font(.caption)
                .foregroundStyle(.secondary)

            HStack {
                // Country code prefix (static; swap for CountryPicker for full i18n)
                Text("+1")
                    .foregroundStyle(.secondary)
                    .padding(.leading, 12)

                TextField("(555) 000-0000", text: $display)
                    .keyboardType(.phonePad)
                    .textContentType(.telephoneNumber)
                    .focused($focused)
                    .accessibilityLabel("Phone number field")
                    .accessibilityHint("Enter your 10-digit US phone number")
                    .onChange(of: display) { _, newValue in
                        let raw = newValue.filter(\.isNumber)
                        rawDigits = String(raw.prefix(10))
                        display   = formatPhone(rawDigits)
                        isInvalid = false
                    }
                    .onSubmit {
                        guard isValid else { isInvalid = true; return }
                        onCommit(rawDigits)
                    }

                if isValid {
                    Image(systemName: "checkmark.circle.fill")
                        .foregroundStyle(.green)
                        .padding(.trailing, 12)
                        .transition(.scale.combined(with: .opacity))
                        .accessibilityLabel("Valid phone number")
                }
            }
            .padding(.vertical, 12)
            .background(
                RoundedRectangle(cornerRadius: 12)
                    .strokeBorder(
                        isInvalid  ? Color.red :
                        focused    ? Color.accentColor :
                                     Color(uiColor: .separator),
                        lineWidth: focused ? 2 : 1
                    )
            )
            .animation(.easeInOut(duration: 0.2), value: focused)
            .animation(.easeInOut(duration: 0.2), value: isValid)

            if isInvalid {
                Text("Please enter a valid 10-digit number.")
                    .font(.caption)
                    .foregroundStyle(.red)
                    .transition(.opacity)
                    .accessibilityLiveRegion(.polite)
            }

            Button {
                guard isValid else { isInvalid = true; return }
                focused = false
                onCommit(rawDigits)
            } label: {
                Text("Continue")
                    .frame(maxWidth: .infinity)
            }
            .buttonStyle(.borderedProminent)
            .controlSize(.large)
            .disabled(!isValid)
            .padding(.top, 8)
        }
        .padding()
    }
}

// MARK: – Preview
#Preview("Phone Input") {
    PhoneNumberInputView { digits in
        print("Submitted:", digits)
    }
    .padding()
}

How it works

  1. Two-state separation. rawDigits is always a clean numeric string of up to 10 characters; display is what the TextField actually renders. This means your validation logic never has to parse punctuation out of user input.
  2. onChange two-closure form. iOS 17 deprecated the single-value onChange(of:perform:) overload. The snippet uses onChange(of: display) { _, newValue in … }, which receives both old and new values and compiles without warnings on Xcode 16.
  3. formatPhone(_:) helper. A simple for loop over character indices inserts (, ) , and - at positions 0, 3, and 6 respectively. Because we always reformat from rawDigits, deleting characters works correctly — users can't accidentally break the mask.
  4. Focus ring & error state. @FocusState drives the stroke colour of the strokeBorder modifier — accent-coloured when focused, red when isInvalid is true, default separator otherwise. Both animate with .animation(.easeInOut, value:).
  5. Accessibility. .textContentType(.telephoneNumber) tells iOS to suggest contacts from the QuickType bar. The error Text uses .accessibilityLiveRegion(.polite) so VoiceOver announces validation failures without interrupting the user.

Variants

International E.164 format with PhoneNumberFormatter

When you need to support country codes beyond +1, lean on PhoneNumberFormatter (available in iOS 17's Contacts framework) to validate and canonicalise:

import Contacts

func e164(from display: String, regionCode: String = "US") -> String? {
    // CNPhoneNumber normalises the string; check digit count for region
    let stripped = display.filter(\.isNumber)
    // Minimal guard — use libPhoneNumber-iOS for production
    guard stripped.count >= 7 else { return nil }
    // Prepend region dial code stored separately (e.g. "+1")
    return "+\(stripped)"
}

// Usage inside onChange:
let canonical = e164(from: display, regionCode: selectedRegion)
isValid = canonical != nil

Read-only formatted display

To show a stored phone number as formatted text (not editable), call formatPhone(digits) directly inside a Text view. No TextField or state management needed:
Text(formatPhone("5551234567")) // → (555) 123-4567

Common pitfalls

Prompt this with Claude Code

When using Soarias or Claude Code directly to implement this:

Implement a phone number input in SwiftUI for iOS 17+.
Use TextField with a custom formatPhone() helper and @FocusState.
Apply .keyboardType(.phonePad) and .textContentType(.telephoneNumber).
Expose a rawDigits binding so parent forms can validate E.164.
Make it accessible (VoiceOver labels, .accessibilityLiveRegion for errors).
Add a #Preview with realistic sample data showing both empty and pre-filled states.

Drop this prompt into Soarias during the Build phase after your screen mockups are approved — Claude Code will wire the component into your existing form file without breaking surrounding layout.

Related

FAQ

Does this work on iOS 16?

Partially. The two-argument onChange(of:) { _, new in } syntax requires iOS 17. On iOS 16 use onChange(of: display, perform: { new in … }) instead. The #Preview macro and @FocusState both work back to iOS 15.

How do I support international phone numbers beyond the US?

Pair this field with a country picker that stores a dial code (e.g. "+44") and adjust both the digit cap and the mask pattern per region. For production apps consider the libPhoneNumber-iOS Swift Package, which provides region-aware validation and formatting for every country.

What's the UIKit equivalent?

In UIKit you'd subclass UITextField and implement textField(_:shouldChangeCharactersIn:replacementString:) from UITextFieldDelegate to intercept and reformat each keystroke. The SwiftUI onChange approach is equivalent but requires no delegate pattern or subclassing.

Last reviewed: 2026-05-12 by the Soarias team.

```