How to Build a Credit Card Form in SwiftUI
Bind each card field to a @State string, strip non-digits in onChange(of:), and validate with computed Bool properties. Gate the submit button with .disabled(!isFormValid).
struct CardNumberField: View {
@State private var cardNumber = ""
var isValid: Bool { cardNumber.count == 16 }
var body: some View {
TextField("1234 5678 9012 3456", text: $cardNumber)
.keyboardType(.numberPad)
.onChange(of: cardNumber) { _, new in
cardNumber = String(new.filter(\.isNumber).prefix(16))
}
.padding(10)
.overlay(RoundedRectangle(cornerRadius: 10)
.stroke(isValid ? Color.green : Color.red, lineWidth: 1.5))
}
}
Full implementation
The implementation breaks into three composable pieces: a reusable ValidatedField view that shows error states only after interaction, a CardPreviewView that mirrors live input onto a stylised card graphic, and the main CreditCardFormView that wires them together with @FocusState and a gated Pay button. Raw digit storage stays separate from display formatting so validation logic stays simple.
import SwiftUI
// MARK: - Main form
struct CreditCardFormView: View {
@State private var cardNumber = ""
@State private var cardHolder = ""
@State private var expiryDate = ""
@State private var cvv = ""
@State private var isSubmitted = false
@FocusState private var focusedField: CardField?
enum CardField: Hashable {
case number, holder, expiry, cvv
}
// Formatted for the preview card only
var formattedNumber: String {
var result = ""
for (i, ch) in cardNumber.prefix(16).enumerated() {
if i > 0, i.isMultiple(of: 4) { result += " " }
result.append(ch)
}
return result
}
var isCardNumberValid: Bool { cardNumber.count == 16 }
var isHolderValid: Bool { !cardHolder.trimmingCharacters(in: .whitespaces).isEmpty }
var isCVVValid: Bool { (3...4).contains(cvv.count) }
var isExpiryValid: Bool {
guard expiryDate.count == 5 else { return false }
let parts = expiryDate.split(separator: "/")
guard parts.count == 2,
let month = Int(parts[0]) else { return false }
return (1...12).contains(month)
}
var isFormValid: Bool {
isCardNumberValid && isHolderValid && isExpiryValid && isCVVValid
}
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: 20) {
CardPreviewView(
number: formattedNumber,
holder: cardHolder,
expiry: expiryDate
)
VStack(spacing: 16) {
ValidatedField(
label: "Card Number",
text: $cardNumber,
placeholder: "1234 5678 9012 3456",
keyboardType: .numberPad,
isValid: isCardNumberValid,
errorMessage: "Must be 16 digits"
) { new in
cardNumber = String(new.filter(\.isNumber).prefix(16))
}
.focused($focusedField, equals: .number)
ValidatedField(
label: "Cardholder Name",
text: $cardHolder,
placeholder: "Jane Doe",
keyboardType: .default,
isValid: isHolderValid,
errorMessage: "Name is required"
)
.focused($focusedField, equals: .holder)
.textContentType(.name)
HStack(spacing: 12) {
ValidatedField(
label: "Expiry (MM/YY)",
text: $expiryDate,
placeholder: "MM/YY",
keyboardType: .numberPad,
isValid: isExpiryValid,
errorMessage: "Invalid date"
) { new in
let d = String(new.filter(\.isNumber).prefix(4))
expiryDate = d.count >= 3
? d.prefix(2) + "/" + d.dropFirst(2)
: String(d)
}
.focused($focusedField, equals: .expiry)
ValidatedField(
label: "CVV",
text: $cvv,
placeholder: "123",
keyboardType: .numberPad,
isValid: isCVVValid,
errorMessage: "3–4 digits"
) { new in
cvv = String(new.filter(\.isNumber).prefix(4))
}
.focused($focusedField, equals: .cvv)
}
}
.padding()
.background(.background)
.clipShape(RoundedRectangle(cornerRadius: 16))
.shadow(color: .black.opacity(0.06), radius: 8, y: 2)
Button {
focusedField = nil
isSubmitted = true
} label: {
Text("Pay Now")
.font(.headline)
.frame(maxWidth: .infinity)
.padding()
.background(isFormValid ? Color.blue : Color.gray.opacity(0.25))
.foregroundStyle(.white)
.clipShape(RoundedRectangle(cornerRadius: 14))
.animation(.easeInOut(duration: 0.2), value: isFormValid)
}
.disabled(!isFormValid)
.accessibilityLabel("Submit payment")
}
.padding()
}
.navigationTitle("Payment")
.background(Color(.systemGroupedBackground))
.alert("Payment Submitted!", isPresented: $isSubmitted) {
Button("Done", role: .cancel) {}
}
}
}
}
// MARK: - Reusable validated field
struct ValidatedField: View {
let label: String
@Binding var text: String
let placeholder: String
let keyboardType: UIKeyboardType
let isValid: Bool
let errorMessage: String
var onCommit: ((String) -> Void)?
@State private var hasInteracted = false
var showError: Bool { hasInteracted && !text.isEmpty && !isValid }
var borderColor: Color {
if showError { return .red }
if hasInteracted && isValid { return .green }
return Color(.systemGray4)
}
var body: some View {
VStack(alignment: .leading, spacing: 4) {
Text(label)
.font(.caption.weight(.medium))
.foregroundStyle(.secondary)
.accessibilityHidden(true)
TextField(placeholder, text: $text)
.keyboardType(keyboardType)
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
.padding(10)
.background(Color(.secondarySystemBackground))
.clipShape(RoundedRectangle(cornerRadius: 10))
.overlay(
RoundedRectangle(cornerRadius: 10)
.stroke(borderColor, lineWidth: 1.5)
)
.onChange(of: text) { _, newValue in
hasInteracted = true
onCommit?(newValue)
}
.accessibilityLabel(label)
if showError {
Text(errorMessage)
.font(.caption2)
.foregroundStyle(.red)
.transition(.opacity.combined(with: .move(edge: .top)))
}
}
.animation(.easeInOut(duration: 0.15), value: showError)
}
}
// MARK: - Card preview
struct CardPreviewView: View {
let number: String
let holder: String
let expiry: String
var displayNumber: String {
let filled = number.isEmpty ? "" : number
let placeholder = "•••• •••• •••• ••••"
guard !filled.isEmpty else { return placeholder }
let end = placeholder.index(placeholder.startIndex, offsetBy: min(filled.count, 19))
return filled + placeholder[end...]
}
var body: some View {
ZStack(alignment: .leading) {
RoundedRectangle(cornerRadius: 20)
.fill(LinearGradient(
colors: [Color(red: 0.18, green: 0.35, blue: 0.92),
Color(red: 0.52, green: 0.14, blue: 0.85)],
startPoint: .topLeading,
endPoint: .bottomTrailing
))
.shadow(color: .blue.opacity(0.35), radius: 14, y: 8)
VStack(alignment: .leading, spacing: 18) {
HStack {
Text("VISA")
.font(.title2.bold().italic())
.foregroundStyle(.white)
Spacer()
Image(systemName: "creditcard.fill")
.font(.title2)
.foregroundStyle(.white.opacity(0.75))
}
Text(displayNumber)
.font(.system(.title3, design: .monospaced).weight(.medium))
.foregroundStyle(.white)
.kerning(2)
HStack(alignment: .bottom) {
VStack(alignment: .leading, spacing: 2) {
Text("CARDHOLDER")
.font(.system(size: 9, weight: .medium))
.foregroundStyle(.white.opacity(0.65))
Text(holder.isEmpty ? "YOUR NAME" : holder.uppercased())
.font(.subheadline.bold())
.foregroundStyle(.white)
.lineLimit(1)
}
Spacer()
VStack(alignment: .trailing, spacing: 2) {
Text("EXPIRES")
.font(.system(size: 9, weight: .medium))
.foregroundStyle(.white.opacity(0.65))
Text(expiry.isEmpty ? "MM/YY" : expiry)
.font(.subheadline.bold())
.foregroundStyle(.white)
}
}
}
.padding(24)
}
.frame(height: 200)
.accessibilityElement(children: .ignore)
.accessibilityLabel("Card preview")
}
}
#Preview {
CreditCardFormView()
}
How it works
-
Raw-digit storage pattern. Each
@Statestring stores only the raw digit characters (e.g.cardNumber = "4111111111111111"). TheonCommitclosure inValidatedFieldimmediately strips any non-digit character and caps the length before writing back, so validation math stays simple:cardNumber.count == 16. -
Live card preview via
formattedNumber. The computed property inserts a space every four characters into the stored raw string and passes the result toCardPreviewView. This keeps the binding clean while the preview mirrors every keystroke. -
Deferred error display with
hasInteracted.ValidatedFieldowns a local@State private var hasInteracted = falseflag that flips totruethe first timeonChangefires. The red border only renders whenhasInteracted && !text.isEmpty && !isValid, preventing the entire form from looking broken on first load. -
Expiry auto-slash injection. In the expiry
onCommitclosure, once three or more digit characters exist the closure inserts a literal/after the second character (d.prefix(2) + "/" + d.dropFirst(2)). This produces the familiarMM/YYformat without a separate masked-input library. -
Gated submit button.
isFormValidANDs all four field validators and drives both.disabled(!isFormValid)and the background color change. The.animation(.easeInOut, value: isFormValid)modifier smoothly transitions the button colour as the user completes each field.
Variants
Auto-advance focus between fields
Automatically jump to the next field when a field reaches its maximum length — reduces taps for the user.
// In CreditCardFormView, add this modifier to the card number ValidatedField:
.onChange(of: cardNumber) { _, new in
if new.count == 16 { focusedField = .holder }
}
// Similarly advance from holder → expiry → cvv:
.onChange(of: expiryDate) { _, new in
// expiryDate raw digits: strip "/" to check actual digit count
let digits = new.filter(\.isNumber)
if digits.count == 4 { focusedField = .cvv }
}
// Dismiss keyboard when CVV is complete:
.onChange(of: cvv) { _, new in
if new.count >= 3 { focusedField = nil }
}
Card network detection (Visa / Mastercard / Amex)
Derive the card network from the first digits of cardNumber and swap the preview logo and CVV length accordingly:
enum CardNetwork {
case visa, mastercard, amex, unknown
init(digits: String) {
switch digits.prefix(2) {
case let p where p.hasPrefix("4"): self = .visa
case "51"..."55", "22"..."27": self = .mastercard
case "34", "37": self = .amex
default: self = .unknown
}
}
var cvvLength: Int { self == .amex ? 4 : 3 }
var maxCardLen: Int { self == .amex ? 15 : 16 }
var logoName: String {
switch self {
case .visa: return "VISA"
case .mastercard: return "MC"
case .amex: return "AMEX"
case .unknown: return "CARD"
}
}
}
// Usage in CreditCardFormView:
var network: CardNetwork { CardNetwork(digits: cardNumber) }
var isCVVValid: Bool { cvv.count == network.cvvLength }
Common pitfalls
-
iOS 17 onChange signature. The two-argument closure
onChange(of:) { _, newValue in }is required on iOS 17+. Using the single-argument form{ newValue in }compiles with a deprecation warning and will be removed — always use the new form. -
Mutation loop with formatted display values. Never write the formatted string (e.g. with spaces) back into the same binding that feeds the
TextField. This triggers anotheronChangecallback, causing an infinite update loop. Keep raw digits in state and format only for display in computed properties or a separate preview view. -
Number-pad hides the Return key.
.keyboardType(.numberPad)shows no done/return key by default on iPhone. Add a toolbar with a "Done" button or use.toolbar { ToolbarItemGroup(placement: .keyboard) { Spacer(); Button("Done") { focusedField = nil } } }to give users a way to dismiss without tapping outside. -
Accessibility: avoid placeholder-only labels. Placeholder text disappears once typing begins. Always pair your
TextFieldwith an explicit.accessibilityLabel(label)so VoiceOver users know which field they are in after entering a value.
Prompt this with Claude Code
When using Soarias or Claude Code directly to implement this:
Implement a credit card form in SwiftUI for iOS 17+. Use TextField, @FocusState, and real-time Validation. Include card number masking (16 digits), expiry (MM/YY) auto-slash injection, CVV length validation, and a card network detector (Visa / Mastercard / Amex). Make every field accessible (VoiceOver labels, no placeholder-only identification). Add a #Preview with realistic sample data pre-filled.
Paste this prompt during the Build phase in Soarias — it maps directly onto a single screen task and generates copy-paste ready code with no further scaffolding needed.
Related
FAQ
Does this work on iOS 16?
Not as written. The two-argument onChange(of:) { _, new in } closure and the #Preview macro both require iOS 17 / Xcode 15+. To backport to iOS 16, switch to the single-argument onChange { new in } (deprecated but functional) and replace #Preview with a PreviewProvider struct. The rest of the form logic is iOS 15+ compatible.
How do I prevent the card number from showing in the keyboard auto-fill bar?
Set .textContentType(.creditCardNumber) if you want system auto-fill (iOS will offer saved cards), or .textContentType(.none) to suppress it entirely. Avoid .autocorrectionDisabled() alone — that only disables spell-check, not credential auto-fill. For PCI compliance, consider SecureField for CVV and confirm with your payment processor whether raw field entry is permitted.
What is the UIKit equivalent?
In UIKit you would subclass UITextField and implement textField(_:shouldChangeCharactersIn:replacementString:) from UITextFieldDelegate to intercept and transform input. You would also call addTarget(_:action:for: .editingChanged) to trigger validation. The SwiftUI onChange(of:) + @Binding pattern replaces both delegate callbacks and target-action, with no subclassing required.
Last reviewed: 2026-05-12 by the Soarias team.