Skip to content

Masked Text Field

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

Masked Text Field, playing
Download .swift
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

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))
}
}
ParameterTypeDefault
titleLocalizedStringResourceRequired
textBinding<String>Required
maskStringRequired
promptLocalizedStringResource?nil
systemImageString?nil
keyboardUIKeyboardType.numberPad
isValid(String) -> Bool{ _ in true }

Copy this code into your project, or download the file.

MaskedTextField.swift
// 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()
}