Masked Text Field
A text field that formats input as the user types, such as 12/34 or a phone number, using a mask.

- Inputs
- iOS 18.0+
- intermediate
- Origin
- Original work
- Found by
- text field · mask · input mask · format · phone number · credit card · expiry date · campo de texto · máscara · formato · número de teléfono · tarjeta de crédito · fecha de caducidad
Description
Section titled “Description”A text field that formats its input as the user types, such as 12/34 or a phone number.
Editing in the middle of the text moves the cursor to the end, because the field rewrites its text. The checkmark shows when the mask is full and isValid agrees, which is where to check what a mask cannot, like a month over 12. With Reduce Motion on, it appears without animating.
struct MaskedTextFieldDemo: View { @State private var card = "" @State private var expiry = "" @State private var phone = ""
var body: some View { ScrollView { VStack(alignment: .leading, spacing: 20) { Text("Payment details") .font(.largeTitle.bold())
MaskedTextField(title: "Card number", text: $card, mask: "#### #### #### ####", systemImage: "creditcard.fill") MaskedTextField(title: "Expiry date", text: $expiry, mask: "##/##", prompt: "MM/YY", systemImage: "calendar") { expiry in (1...12).contains(Int(expiry.prefix(2)) ?? 0) } MaskedTextField(title: "Phone", text: $phone, mask: "(###) ###-####", systemImage: "phone.fill") } .padding(20) } .background(Color(uiColor: .systemGroupedBackground)) }}API reference
Section titled “API reference”| Parameter | Type | Default |
|---|---|---|
title | LocalizedStringResource | Required |
text | Binding<String> | Required |
mask | String | Required |
prompt | LocalizedStringResource? | nil |
systemImage | String? | nil |
keyboard | UIKeyboardType | .numberPad |
isValid | (String) -> Bool | { _ in true } |
Copy this code into your project, or download the file.
// SPDX-License-Identifier: MIT// Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors
import SwiftUI
/// Applies a mask where `#` takes a digit, `A` takes a letter and anything else is written as is.nonisolated enum MaskedTextFieldFormatter { private static func accepts(_ character: Character, slot: Character) -> Bool? { switch slot { case "#": character.isWholeNumber case "A": character.isLetter default: nil } }
private static func isLiteral(_ slot: Character) -> Bool { slot != "#" && slot != "A" }
/// The part of a run of literals that has to be there to read it back: `+34` for `+34 `, so a /// pasted number without the separator is recognized. A run like `1 (` has to be there whole, /// because without its separators it is just a `1` the user could be typing. private static func readBackPrefix(of literals: Substring) -> Substring { let isData: (Character) -> Bool = { $0.isWholeNumber || $0.isLetter } guard let end = literals.lastIndex(where: isData), !literals[...end].allSatisfy(isData) else { return literals } return literals[...end] }
/// Literals are added only when a character follows them, so backspace can always delete. /// A run of literals is read back only when its prefix is there complete, so a typed `3` is /// not taken for the `3` of a `+34 ` prefix. static func format(_ input: String, mask: String) -> String { guard !mask.isEmpty else { return input }
var result = "" var pendingLiterals = "" var remaining = Substring(input)
var slots = Substring(mask)
while let slot = slots.first { guard !remaining.isEmpty else { return result }
if isLiteral(slot) { let literals = slots.prefix(while: isLiteral) slots.removeFirst(literals.count) pendingLiterals += literals let prefix = readBackPrefix(of: literals) if remaining.hasPrefix(prefix) { remaining.removeFirst(prefix.count) } continue }
slots.removeFirst() while let next = remaining.first, accepts(next, slot: slot) == false { remaining.removeFirst() } guard let character = remaining.first else { return result }
result += pendingLiterals pendingLiterals = "" result.append(character) remaining.removeFirst() } return result }
static func isComplete(_ text: String, mask: String) -> Bool { !mask.isEmpty && format(text, mask: mask).count == mask.count }}
/// A text field that formats its input as the user types, such as `12/34` or a phone number.////// Editing in the middle of the text moves the cursor to the end, because the field rewrites its/// text. The checkmark shows when the mask is full and `isValid` agrees, which is where to/// check what a mask cannot, like a month over 12. With Reduce Motion on, it appears without/// animating.struct MaskedTextField: View { let title: LocalizedStringResource @Binding var text: String let mask: String var prompt: LocalizedStringResource? = nil var systemImage: String? = nil var keyboard: UIKeyboardType = .numberPad var isValid: (String) -> Bool = { _ in true }
@Environment(\.accessibilityReduceMotion) private var reduceMotion
private var isComplete: Bool { MaskedTextFieldFormatter.isComplete(text, mask: mask) && isValid(text) }
var body: some View { VStack(alignment: .leading, spacing: 6) { Text(title) .font(.subheadline.weight(.medium)) .foregroundStyle(.secondary) .accessibilityHidden(true)
HStack(spacing: 10) { if let systemImage { Image(systemName: systemImage) .foregroundStyle(.secondary) .accessibilityHidden(true) }
TextField(text: $text, prompt: prompt.map { Text($0) } ?? Text(verbatim: mask)) { Text(title) } .keyboardType(keyboard) .textInputAutocapitalization(.never) .autocorrectionDisabled() .monospacedDigit() .accessibilityLabel(title) .accessibilityHint(isComplete ? "Complete" : "")
if isComplete { Image(systemName: "checkmark.circle.fill") .foregroundStyle(.green) .transition(.opacity) .accessibilityHidden(true) } } .padding(14) .background(Color(uiColor: .secondarySystemGroupedBackground), in: .rect(cornerRadius: 14)) .overlay { RoundedRectangle(cornerRadius: 14) .stroke(isComplete ? Color.green : .clear, lineWidth: 1.5) } } .animation(reduceMotion ? nil : .snappy(duration: 0.25), value: isComplete) .onChange(of: text, initial: true) { _, newValue in let formatted = MaskedTextFieldFormatter.format(newValue, mask: mask) if formatted != newValue { text = formatted } } }}
// MARK: - Demo
struct MaskedTextFieldDemo: View { @State private var card = "" @State private var expiry = "" @State private var phone = ""
var body: some View { ScrollView { VStack(alignment: .leading, spacing: 20) { Text("Payment details") .font(.largeTitle.bold())
MaskedTextField(title: "Card number", text: $card, mask: "#### #### #### ####", systemImage: "creditcard.fill") MaskedTextField(title: "Expiry date", text: $expiry, mask: "##/##", prompt: "MM/YY", systemImage: "calendar") { expiry in (1...12).contains(Int(expiry.prefix(2)) ?? 0) } MaskedTextField(title: "Phone", text: $phone, mask: "(###) ###-####", systemImage: "phone.fill") } .padding(20) } .background(Color(uiColor: .systemGroupedBackground)) }}
#Preview { MaskedTextFieldDemo()}