Prerequisites
Before starting, make sure you have:- Web
- Android
- iOS
- Installed Primer Checkout
- Created your first payment
- Familiarity with layout customization
- A working checkout integration (First Payment)
- Familiarity with the controller pattern
- Understanding of PrimerCheckoutHost for inline integration
- A working checkout integration (First Payment)
- Familiarity with layout customization
- Understanding of
PrimerCardFormand its@ViewBuilderslots for card form control
Understanding the card form architecture
- Web
- Android
- iOS
The
<primer-card-form> component provides a customizable card payment interface with PCI-compliant hosted inputs. Here’s how the components relate to each other:Security by designThe card input components (
primer-input-card-number, primer-input-card-expiry, primer-input-cvv) render secure iframes that isolate sensitive card data. This means:- Card data never touches your page’s DOM
- Your integration remains PCI-compliant
- Styling is applied through CSS variables that are passed to the iframe
Key components
| Component | Purpose |
|---|---|
primer-card-form | Container that provides context for all card inputs |
primer-input-card-number | Secure hosted input for card number |
primer-input-card-expiry | Secure hosted input for expiry date |
primer-input-cvv | Secure hosted input for CVV |
primer-input-card-holder-name | Input for cardholder name (optional) |
primer-card-form-submit | Submit button with built-in loading states |
The
PrimerCardFormController gives you full control over the card form. You create the controller, observe its state, and build your own UI with custom TextField inputs bound to the controller.Key state properties
| Property | Type | Description |
|---|---|---|
data | Map<PrimerInputElementType, String> | Current field values |
fieldErrors | List<SyncValidationError>? | Validation errors per field |
isFormValid | Boolean | Whether all required fields pass validation |
isLoading | Boolean | Whether a submission is in progress |
PrimerCardForm is a SwiftUI view that gives you full control over the card form through three @ViewBuilder section slots: cardDetails, billingAddress, and submitButton. Each slot receives a PrimerCardFormSession, which publishes the form state and exposes the field-update and submit() methods. Compose your layout from the CardFormDefaults per-field building blocks (cardNumber, expiryDate, cvv, cardholderName). Primer handles validation, formatting, and PCI compliance.Key state properties
The session exposes its state throughsession.state, a PrimerCardFormState:| Property | Type | Description |
|---|---|---|
isValid | Bool | Whether all required fields pass validation |
isLoading | Bool | Whether a submission is in progress |
fieldErrors | [FieldError] | Validation errors per field |
data | FormData | Current field values keyed by PrimerInputElementType |
Step 1: Create the card form
- Web
- Android
- iOS
Start by creating a custom card form using the
card-form-content slot:<primer-checkout client-token="your-client-token">
<primer-main slot="main">
<div slot="payments">
<primer-card-form>
<div slot="card-form-content">
<primer-input-card-number></primer-input-card-number>
<primer-input-card-expiry></primer-input-card-expiry>
<primer-input-cvv></primer-input-cvv>
<primer-input-card-holder-name></primer-input-card-holder-name>
<primer-card-form-submit>Pay Now</primer-card-form-submit>
</div>
</primer-card-form>
</div>
</primer-main>
</primer-checkout>
Component hierarchy mattersAll card input components must be nested inside
<primer-card-form>. Placing them outside breaks the context connection and the form won’t work.<!-- WRONG: Inputs outside primer-card-form -->
<primer-card-form></primer-card-form>
<primer-input-card-number></primer-input-card-number>
<!-- CORRECT: Inputs inside primer-card-form -->
<primer-card-form>
<div slot="card-form-content">
<primer-input-card-number></primer-input-card-number>
</div>
</primer-card-form>
Create a
PrimerCardFormController inside a PrimerCheckoutHost. The controller manages field values, validation, and submission.@Composable
fun CustomCardFormScreen(clientToken: String) {
val checkout = rememberPrimerCheckoutController(clientToken)
val checkoutState by checkout.state.collectAsStateWithLifecycle()
LaunchedEffect(checkoutState) {
when (val s = checkoutState) {
is PrimerCheckoutState.Success -> { /* navigate */ }
is PrimerCheckoutState.Failure -> { /* log error */ }
else -> Unit
}
}
when (checkoutState) {
is PrimerCheckoutState.Loading -> CircularProgressIndicator()
is PrimerCheckoutState.Ready -> {
PrimerCheckoutHost(checkout = checkout) {
val cardFormController = rememberCardFormController(checkout)
CustomCardForm(cardFormController)
}
}
}
}
Hold a
PrimerCheckoutSession as a @StateObject and place a PrimerCardForm inside a view that has the .primerCheckoutSession(_:onCompletion:) modifier applied. PrimerCardForm resolves its PrimerCardFormSession from the environment, so it works inline anywhere under the modifier.struct CustomCardFormScreen: View {
@StateObject private var session: PrimerCheckoutSession
init(clientToken: String) {
_session = StateObject(wrappedValue: PrimerCheckoutSession(clientToken: clientToken))
}
var body: some View {
ScrollView {
PrimerCardForm()
}
.primerCheckoutSession(session) { state in
// Handle the terminal PrimerCheckoutState
}
}
}
Session resolution matters
PrimerCardForm and its slots resolve the active PrimerCardFormSession from the environment. Place the form inside a view that has the .primerCheckoutSession(_:onCompletion:) modifier applied, or the form renders an empty placeholder.Step 2: Customize the layout
- Web
- Android
- iOS
Vertical layout (default)
Stack inputs vertically for a clean, mobile-friendly form:<primer-card-form>
<div slot="card-form-content" class="card-form-vertical">
<primer-input-card-number></primer-input-card-number>
<primer-input-card-expiry></primer-input-card-expiry>
<primer-input-cvv></primer-input-cvv>
<primer-input-card-holder-name></primer-input-card-holder-name>
<primer-card-form-submit>Pay Now</primer-card-form-submit>
</div>
</primer-card-form>
.card-form-vertical {
display: flex;
flex-direction: column;
gap: var(--primer-space-small);
}
Grouped layout
Place expiry and CVV side by side:<primer-card-form>
<div slot="card-form-content" class="card-form-grouped">
<primer-input-card-number></primer-input-card-number>
<div class="row">
<primer-input-card-expiry></primer-input-card-expiry>
<primer-input-cvv></primer-input-cvv>
</div>
<primer-input-card-holder-name></primer-input-card-holder-name>
<primer-card-form-submit>Pay Now</primer-card-form-submit>
</div>
</primer-card-form>
.card-form-grouped {
display: flex;
flex-direction: column;
gap: var(--primer-space-small);
}
.card-form-grouped .row {
display: flex;
gap: var(--primer-space-small);
}
.card-form-grouped .row > * {
flex: 1;
}
Responsive layout
Adapt the layout based on screen size:.card-form-responsive {
display: flex;
flex-direction: column;
gap: var(--primer-space-small);
}
.card-form-responsive .row {
display: flex;
flex-direction: column;
gap: var(--primer-space-small);
}
@media (min-width: 480px) {
.card-form-responsive .row {
flex-direction: row;
}
.card-form-responsive .row > * {
flex: 1;
}
}
Observe form state and bind each
OutlinedTextField to the controller’s update methods. The controller handles formatting (card number grouping, expiry date slashes) internally.@Composable
fun CustomCardForm(controller: PrimerCardFormController) {
val formState by controller.state.collectAsStateWithLifecycle()
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
OutlinedTextField(
value = formState.data[PrimerInputElementType.CARD_NUMBER].orEmpty(),
onValueChange = { controller.updateCardNumber(it) },
label = { Text("Card number") },
isError = hasFieldError(formState, PrimerInputElementType.CARD_NUMBER),
supportingText = fieldErrorText(formState, PrimerInputElementType.CARD_NUMBER),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
OutlinedTextField(
value = formState.data[PrimerInputElementType.EXPIRY_DATE].orEmpty(),
onValueChange = { controller.updateExpiryDate(it) },
label = { Text("MM/YY") },
isError = hasFieldError(formState, PrimerInputElementType.EXPIRY_DATE),
supportingText = fieldErrorText(formState, PrimerInputElementType.EXPIRY_DATE),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
singleLine = true,
modifier = Modifier.weight(1f),
)
OutlinedTextField(
value = formState.data[PrimerInputElementType.CVV].orEmpty(),
onValueChange = { controller.updateCvv(it) },
label = { Text("CVV") },
isError = hasFieldError(formState, PrimerInputElementType.CVV),
supportingText = fieldErrorText(formState, PrimerInputElementType.CVV),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
singleLine = true,
modifier = Modifier.weight(1f),
)
}
OutlinedTextField(
value = formState.data[PrimerInputElementType.CARDHOLDER_NAME].orEmpty(),
onValueChange = { controller.updateCardholderName(it) },
label = { Text("Cardholder name") },
isError = hasFieldError(formState, PrimerInputElementType.CARDHOLDER_NAME),
supportingText = fieldErrorText(formState, PrimerInputElementType.CARDHOLDER_NAME),
keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Words),
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
}
}
Override the
cardDetails slot and recompose the form from the CardFormDefaults per-field building blocks. Each block keeps the SDK’s formatting, validation, and inline error display, and is self-hiding when its field is not part of the active configuration.Vertical layout
PrimerCardForm(cardDetails: { session in
VStack(spacing: 16) {
CardFormDefaults.cardNumber(session)
CardFormDefaults.expiryDate(session)
CardFormDefaults.cvv(session)
CardFormDefaults.cardholderName(session)
// Co-badged selector: shown only when multiple networks are detected.
CardFormDefaults.cardNetwork(session)
}
.padding()
})
Grouped layout
Place expiry and CVV side by side:PrimerCardForm(cardDetails: { session in
VStack(spacing: 16) {
CardFormDefaults.cardNumber(session)
HStack(spacing: 12) {
CardFormDefaults.expiryDate(session)
CardFormDefaults.cvv(session)
}
CardFormDefaults.cardholderName(session)
CardFormDefaults.cardNetwork(session)
}
})
When you recompose the individual card fields, add
CardFormDefaults.cardNetwork(session) yourself so co-badged cards still get a selector — the built-in one is suppressed once you replace the default cardDetails section. The submit button stays at its default CardFormDefaults.submitButton unless you also override the submitButton slot.Step 3: Style the inputs
- Web
- Android
- iOS
Style card inputs using CSS variables. These properties are passed through to the secure iframes:
primer-card-form {
/* Input styling */
--primer-input-height: 48px;
--primer-input-padding: 12px 16px;
--primer-input-border-radius: 8px;
/* Colors */
--primer-color-background-input-default: #ffffff;
--primer-color-border-input-default: #e0e0e0;
--primer-color-border-input-focus: #2f98ff;
--primer-color-border-input-error: #f44336;
/* Typography */
--primer-input-font-size: 16px;
--primer-input-font-family: system-ui, sans-serif;
--primer-color-text-input: #333333;
--primer-color-text-placeholder: #999999;
}
For a complete list of CSS variables, see the Styling guide.
Adding custom labels
Wrap inputs with labels for better accessibility:<primer-card-form>
<div slot="card-form-content" class="card-form-with-labels">
<label class="input-group">
<span class="label-text">Card Number</span>
<primer-input-card-number></primer-input-card-number>
</label>
<div class="row">
<label class="input-group">
<span class="label-text">Expiry Date</span>
<primer-input-card-expiry></primer-input-card-expiry>
</label>
<label class="input-group">
<span class="label-text">CVV</span>
<primer-input-cvv></primer-input-cvv>
</label>
</div>
<label class="input-group">
<span class="label-text">Cardholder Name</span>
<primer-input-card-holder-name></primer-input-card-holder-name>
</label>
<primer-card-form-submit>Pay Now</primer-card-form-submit>
</div>
</primer-card-form>
.input-group {
display: flex;
flex-direction: column;
gap: 4px;
}
.label-text {
font-size: 14px;
font-weight: 500;
color: var(--primer-color-text-primary);
}
Handle validation errors by extracting error messages from
fieldErrors to display under each field:private fun hasFieldError(
state: PrimerCardFormController.State,
type: PrimerInputElementType,
): Boolean {
return state.fieldErrors?.any { it.inputElementType == type } == true
}
private fun fieldErrorText(
state: PrimerCardFormController.State,
type: PrimerInputElementType,
): (@Composable () -> Unit)? {
val error = state.fieldErrors?.firstOrNull { it.inputElementType == type }
return error?.let {
{ Text(it.errorId) }
}
}
Validation errors appear after the user leaves a field (on blur), not while typing. The SDK handles this timing internally.
Field visual styling is theme-driven. Pass a
PrimerCheckoutTheme to the session and the CardFormDefaults fields pick up its design tokens (colors, spacing, typography, and shape) automatically — there is no per-field styling struct.@StateObject private var session: PrimerCheckoutSession
init(clientToken: String) {
_session = StateObject(
wrappedValue: PrimerCheckoutSession(
clientToken: clientToken,
theme: PrimerCheckoutTheme()
)
)
}
Validation errors appear automatically beneath each field. The SDK handles error timing — errors show after the user leaves a field, not while typing. For the full list of design tokens, see the PrimerCheckoutTheme reference.
Step 4: Handle events and submission
- Web
- Android
- iOS
Listen for events to provide feedback and handle the payment flow:
const cardForm = document.querySelector('primer-card-form');
// Handle validation errors
cardForm.addEventListener('primer:card-error', (event) => {
const { inputType, error } = event.detail;
console.log(`Error in ${inputType}: ${error.message}`);
// Show error feedback to user
showFieldError(inputType, error.message);
});
// Handle successful validation
cardForm.addEventListener('primer:card-success', (event) => {
const { inputType } = event.detail;
console.log(`${inputType} is valid`);
// Clear any previous error
clearFieldError(inputType);
});
For comprehensive event handling patterns, see the Events guide.
Programmatic submission
You can submit the card form programmatically instead of using the built-in submit button:const cardForm = document.querySelector('primer-card-form');
// Your custom submit button
document.getElementById('my-submit-button').addEventListener('click', async () => {
try {
await cardForm.submit();
} catch (error) {
console.error('Submission failed:', error);
}
});
Setting cardholder name programmatically
If you collect the cardholder name elsewhere (e.g., from a shipping form), you can set it programmatically:const cardForm = document.querySelector('primer-card-form');
// Set cardholder name from your form
const name = document.getElementById('shipping-name').value;
cardForm.setCardholderName(name);
When using
setCardholderName(), you don’t need to include the <primer-input-card-holder-name> component in your form.Call
controller.submit() to tokenize the card data. Disable the button when the form is invalid or a submission is in progress:Button(
onClick = { controller.submit() },
enabled = formState.isFormValid && !formState.isLoading,
modifier = Modifier.fillMaxWidth(),
) {
if (formState.isLoading) {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
color = Color.White,
strokeWidth = 2.dp,
)
} else {
Text("Pay")
}
}
Override the
submitButton slot and call session.submit() to tokenize the card data. Read session.state to control the button and show loading:PrimerCardForm(submitButton: { session in
Button(action: { session.submit() }) {
if session.state.isLoading {
ProgressView()
.tint(.white)
} else {
Text("Pay now")
}
}
.frame(maxWidth: .infinity, minHeight: 48)
.buttonStyle(.borderedProminent)
.disabled(!session.state.isValid || session.state.isLoading)
})
Setting cardholder name programmatically
If you collect the name elsewhere, set it on the session without showing the field:session.updateCardholderName("Jane Doe")
When billing or card fields are driven entirely from your own data, omit the matching
CardFormDefaults building block and push the value through the session’s update* methods instead.Step 5: Handle payment completion
- Web
- Android
- iOS
Listen for the checkout state to handle successful payments:
const checkout = document.querySelector('primer-checkout');
checkout.addEventListener('primer:state-change', (event) => {
const state = event.detail;
if (state.isSuccessful) {
// Payment completed successfully
showSuccessMessage();
redirectToConfirmation();
}
if (state.paymentFailure) {
// Payment failed
showErrorMessage(state.paymentFailure.message);
}
});
Handle payment outcomes by observing
checkout.state:val state by checkout.state.collectAsStateWithLifecycle()
LaunchedEffect(state) {
when (val s = state) {
is PrimerCheckoutState.Success -> {
Log.d("Checkout", "Payment ID: ${s.checkoutData.payment.id}")
}
is PrimerCheckoutState.Failure -> {
Log.e("Checkout", "Diagnostics: ${s.error.diagnosticsId}")
}
else -> Unit
}
}
PrimerCheckoutHost(checkout = checkout) {
// Card form content
}
Use the
onCompletion closure on the .primerCheckoutSession(_:onCompletion:) modifier to handle the terminal PrimerCheckoutState:ScrollView {
PrimerCardForm()
}
.primerCheckoutSession(session) { state in
switch state {
case let .success(result):
print("Payment ID: \(result.paymentId)")
case let .failure(error):
print("Error: \(error.localizedDescription)")
case .dismissed:
break
default:
// .initializing and .ready are lifecycle states and are
// never delivered to onCompletion.
break
}
}
Common mistakes to avoid
- Web
- Android
- iOS
Duplicate card forms
Duplicate card forms
Don’t include both
<primer-card-form> and <primer-payment-method type="PAYMENT_CARD"> in your layout. The payment method component already renders a card form internally.<!-- WRONG: Duplicate card forms -->
<div slot="payments">
<primer-card-form>...</primer-card-form>
<primer-payment-method type="PAYMENT_CARD"></primer-payment-method>
</div>
<!-- CORRECT: Use one or the other -->
<div slot="payments">
<primer-card-form>...</primer-card-form>
<primer-payment-method type="PAYPAL"></primer-payment-method>
</div>
Inputs outside the form context
Inputs outside the form context
Card input components must be descendants of
<primer-card-form>. They won’t work if placed outside.<!-- WRONG -->
<div>
<primer-card-form></primer-card-form>
<primer-input-card-number></primer-input-card-number>
</div>
<!-- CORRECT -->
<primer-card-form>
<div slot="card-form-content">
<primer-input-card-number></primer-input-card-number>
</div>
</primer-card-form>
Dynamic rendering timing issues
Dynamic rendering timing issues
When rendering card forms dynamically, ensure the parent
<primer-card-form> exists before adding input children:// WRONG: Adding inputs before the form
container.innerHTML = `
<primer-input-card-number></primer-input-card-number>
<primer-card-form></primer-card-form>
`;
// CORRECT: Form first, then inputs
container.innerHTML = `
<primer-card-form>
<div slot="card-form-content">
<primer-input-card-number></primer-input-card-number>
</div>
</primer-card-form>
`;
Missing slot attribute
Missing slot attribute
When customizing the card form layout, always use the
card-form-content slot:<!-- WRONG: Content not in a slot -->
<primer-card-form>
<div>
<primer-input-card-number></primer-input-card-number>
</div>
</primer-card-form>
<!-- CORRECT: Content in the card-form-content slot -->
<primer-card-form>
<div slot="card-form-content">
<primer-input-card-number></primer-input-card-number>
</div>
</primer-card-form>
Forgetting to observe state
Forgetting to observe state
Always collect the controller’s state using
collectAsStateWithLifecycle(). Without it, your UI won’t update when the user types or when validation changes.// WRONG: State not observed
val formState = controller.state.value
// CORRECT: State observed reactively
val formState by controller.state.collectAsStateWithLifecycle()
Creating the controller outside PrimerCheckoutHost
Creating the controller outside PrimerCheckoutHost
The card form controller must be created within the
PrimerCheckoutHost or PrimerCheckoutSheet scope.// WRONG: Controller outside host
val controller = rememberCardFormController(checkout)
PrimerCheckoutHost(checkout = checkout) {
CustomCardForm(controller)
}
// CORRECT: Controller inside host
PrimerCheckoutHost(checkout = checkout) {
val controller = rememberCardFormController(checkout)
CustomCardForm(controller)
}
Using the session from the wrong slot
Using the session from the wrong slot
Use the
PrimerCardFormSession passed into each slot closure. Capturing a session from elsewhere — or building fields with a session the form did not provide — won’t work.// WRONG: Building a field from an unrelated session reference
PrimerCardForm(cardDetails: { session in
VStack {
CardFormDefaults.cardNumber(otherSession) // Wrong session
}
})
// CORRECT: Use the session parameter from this slot
PrimerCardForm(cardDetails: { session in
VStack {
CardFormDefaults.cardNumber(session)
}
})
Not observing state in a custom subview
Not observing state in a custom subview
When you extract a slot into its own
View, mark the captured session as @ObservedObject. Without it, your UI won’t update when validation changes.// WRONG: Session not observed — button never re-evaluates
struct SubmitButton: View {
let session: PrimerCardFormSession
var body: some View {
Button("Pay") { session.submit() }
.disabled(false) // Always enabled
}
}
// CORRECT: Observe the session so state changes re-render
struct SubmitButton: View {
@ObservedObject var session: PrimerCardFormSession
var body: some View {
Button("Pay") { session.submit() }
.disabled(!session.state.isValid || session.state.isLoading)
}
}
Complete example
- Web
- Android
- iOS
Here’s a complete example combining all the concepts:
<primer-checkout client-token="your-client-token">
<primer-main slot="main">
<div slot="payments">
<h2>Pay with Card</h2>
<primer-card-form>
<div slot="card-form-content" class="custom-card-form">
<label class="input-group">
<span class="label">Card Number</span>
<primer-input-card-number></primer-input-card-number>
</label>
<div class="row">
<label class="input-group">
<span class="label">Expiry</span>
<primer-input-card-expiry></primer-input-card-expiry>
</label>
<label class="input-group">
<span class="label">CVV</span>
<primer-input-cvv></primer-input-cvv>
</label>
</div>
<label class="input-group">
<span class="label">Name on Card</span>
<primer-input-card-holder-name></primer-input-card-holder-name>
</label>
<primer-card-form-submit class="submit-button">
Complete Payment
</primer-card-form-submit>
</div>
</primer-card-form>
<!-- Other payment methods -->
<primer-payment-method type="PAYPAL"></primer-payment-method>
<primer-payment-method type="APPLE_PAY"></primer-payment-method>
</div>
</primer-main>
</primer-checkout>
<style>
.custom-card-form {
display: flex;
flex-direction: column;
gap: 16px;
padding: 20px;
background: var(--primer-color-background-outlined-default);
border-radius: 8px;
}
.input-group {
display: flex;
flex-direction: column;
gap: 4px;
}
.label {
font-size: 14px;
font-weight: 500;
color: var(--primer-color-text-primary);
}
.row {
display: flex;
gap: 16px;
}
.row > * {
flex: 1;
}
.submit-button {
margin-top: 8px;
}
@media (max-width: 480px) {
.row {
flex-direction: column;
}
}
</style>
<script>
const cardForm = document.querySelector('primer-card-form');
const checkout = document.querySelector('primer-checkout');
// Handle field errors
cardForm.addEventListener('primer:card-error', (event) => {
const { inputType, error } = event.detail;
const input = cardForm.querySelector(`primer-input-${inputType}`);
input?.classList.add('has-error');
});
// Clear errors on success
cardForm.addEventListener('primer:card-success', (event) => {
const { inputType } = event.detail;
const input = cardForm.querySelector(`primer-input-${inputType}`);
input?.classList.remove('has-error');
});
// Handle payment completion
checkout.addEventListener('primer:state-change', (event) => {
if (event.detail.isSuccessful) {
window.location.href = '/confirmation';
}
});
</script>
@Composable
fun CustomCardFormScreen(clientToken: String) {
val checkout = rememberPrimerCheckoutController(clientToken)
val checkoutState by checkout.state.collectAsStateWithLifecycle()
LaunchedEffect(checkoutState) {
when (val s = checkoutState) {
is PrimerCheckoutState.Success -> {
Log.d("Checkout", "Payment ID: ${s.checkoutData.payment.id}")
}
is PrimerCheckoutState.Failure -> {
Log.e("Checkout", "Diagnostics: ${s.error.diagnosticsId}")
}
else -> Unit
}
}
when (checkoutState) {
is PrimerCheckoutState.Loading -> {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
}
is PrimerCheckoutState.Ready -> {
PrimerCheckoutHost(checkout = checkout) {
val controller = rememberCardFormController(checkout)
val formState by controller.state.collectAsStateWithLifecycle()
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(
text = "Enter card details",
style = MaterialTheme.typography.titleMedium,
)
OutlinedTextField(
value = formState.data[PrimerInputElementType.CARD_NUMBER].orEmpty(),
onValueChange = { controller.updateCardNumber(it) },
label = { Text("Card number") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
OutlinedTextField(
value = formState.data[PrimerInputElementType.EXPIRY_DATE].orEmpty(),
onValueChange = { controller.updateExpiryDate(it) },
label = { Text("MM/YY") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
singleLine = true,
modifier = Modifier.weight(1f),
)
OutlinedTextField(
value = formState.data[PrimerInputElementType.CVV].orEmpty(),
onValueChange = { controller.updateCvv(it) },
label = { Text("CVV") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
singleLine = true,
modifier = Modifier.weight(1f),
)
}
OutlinedTextField(
value = formState.data[PrimerInputElementType.CARDHOLDER_NAME].orEmpty(),
onValueChange = { controller.updateCardholderName(it) },
label = { Text("Cardholder name") },
keyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.Words,
),
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Button(
onClick = { controller.submit() },
enabled = formState.isFormValid && !formState.isLoading,
modifier = Modifier.fillMaxWidth(),
) {
if (formState.isLoading) {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
color = Color.White,
strokeWidth = 2.dp,
)
} else {
Text("Pay")
}
}
}
}
}
}
}
import SwiftUI
import PrimerSDK
struct CustomCardFormScreen: View {
@StateObject private var session: PrimerCheckoutSession
init(clientToken: String) {
_session = StateObject(wrappedValue: PrimerCheckoutSession(clientToken: clientToken))
}
var body: some View {
ScrollView {
PrimerCardForm(
cardDetails: { formSession in
VStack(spacing: 20) {
Text("Payment details")
.font(.title2)
.fontWeight(.semibold)
.frame(maxWidth: .infinity, alignment: .leading)
CardFormDefaults.cardNumber(formSession)
HStack(spacing: 12) {
CardFormDefaults.expiryDate(formSession)
CardFormDefaults.cvv(formSession)
}
CardFormDefaults.cardholderName(formSession)
// Co-badged selector: shown only when multiple networks are detected.
CardFormDefaults.cardNetwork(formSession)
}
},
submitButton: { formSession in
Button(action: { formSession.submit() }) {
if formSession.state.isLoading {
ProgressView()
.tint(.white)
} else {
Text("Pay now")
}
}
.frame(maxWidth: .infinity, minHeight: 48)
.background(formSession.state.isValid ? Color.blue : Color.gray)
.foregroundColor(.white)
.cornerRadius(10)
.disabled(!formSession.state.isValid || formSession.state.isLoading)
}
)
.padding()
}
.primerCheckoutSession(session) { state in
switch state {
case let .success(result):
print("Payment ID: \(result.paymentId)")
case let .failure(error):
print("Error: \(error.localizedDescription)")
case .dismissed:
break
default:
break
}
}
}
}
See also
Styling guide
Customize colors, fonts, and spacing
Events guide
Handle checkout events across platforms
Error handling
Display and handle payment errors
Android SDK Reference
Android SDK API documentation
iOS SDK Reference
iOS SDK API documentation