<primer-checkout> component and dispatch a custom event. On Android, override the submitButton slot in PrimerCardForm with a custom Composable.
Recipe
- Web
- Android
- iOS
Copy
Ask AI
<primer-checkout client-token="your-token">
<primer-main slot="main">
<primer-card-form></primer-card-form>
</primer-main>
</primer-checkout>
<button id="my-pay-button">Pay Now</button>
Copy
Ask AI
document.getElementById('my-pay-button').addEventListener('click', () => {
document.dispatchEvent(
new CustomEvent('primer:card-submit', {
bubbles: true,
composed: true,
}),
);
});
Copy
Ask AI
@Composable
fun CheckoutScreen(clientToken: String) {
val checkout = rememberPrimerCheckoutController(clientToken)
val state by checkout.state.collectAsStateWithLifecycle()
when (state) {
is PrimerCheckoutState.Loading -> CircularProgressIndicator()
is PrimerCheckoutState.Ready -> {
PrimerCheckoutSheet(
checkout = checkout,
onEvent = { event ->
when (event) {
is PrimerCheckoutEvent.Success -> { /* navigate */ }
is PrimerCheckoutEvent.Failure -> { /* log error */ }
}
},
cardForm = {
val cardFormController = rememberCardFormController(checkout)
val formState by cardFormController.state.collectAsStateWithLifecycle()
PrimerCardForm(
controller = cardFormController,
submitButton = {
BrandedPayButton(
isEnabled = formState.isFormValid && !formState.isLoading,
isLoading = formState.isLoading,
onClick = { cardFormController.submit() },
)
},
)
},
)
}
}
}
Copy
Ask AI
PrimerCheckout(
clientToken: clientToken,
scope: { checkoutScope in
if let cardScope: PrimerCardFormScope = checkoutScope.getPaymentMethodScope(PrimerCardFormScope.self) {
cardScope.screen = { scope in
AnyView(CustomCardForm(scope: scope))
}
}
}
)
struct CustomCardForm: View {
let scope: any PrimerCardFormScope
@State private var isValid = false
@State private var isLoading = false
var body: some View {
VStack(spacing: 16) {
scope.PrimerCardNumberField(label: "Card number", styling: nil)
scope.PrimerExpiryDateField(label: "Expiry", styling: nil)
scope.PrimerCvvField(label: "CVV", styling: nil)
Button(action: { scope.submit() }) {
Text(isLoading ? "Processing..." : "Complete payment")
.frame(maxWidth: .infinity, minHeight: 50)
}
.buttonStyle(.borderedProminent)
.disabled(!isValid || isLoading)
}
.task {
for await state in scope.state {
isValid = state.isValid
isLoading = state.isLoading
}
}
}
}
How it works
- Web
- Android
- iOS
- Place your custom button outside the
<primer-checkout>component - Listen for click events on your button
- Dispatch the
primer:card-submitcustom event to trigger form submission - The event bubbles up to the card form and initiates the payment
The
bubbles: true and composed: true options are required so the event can cross shadow DOM boundaries and reach the card form component.- Create a
PrimerCardFormControllerto access form state - Pass a custom
submitButtontoPrimerCardForm - Use
formState.isFormValidto control the enabled state andformState.isLoadingfor a loading indicator - Call
controller.submit()to trigger payment
- Access
PrimerCardFormScopeviagetPaymentMethodScope()in the scope closure - Replace the
screenproperty with your own layout using SDK-managed fields - Call
scope.submit()from your custom button to trigger payment - Observe
scope.stateto monitorisValidandisLoadingfor button state
Variations
Button with loading state
- Web
- Android
- iOS
Copy
Ask AI
const payButton = document.getElementById('my-pay-button');
const checkout = document.querySelector('primer-checkout');
payButton.addEventListener('click', () => {
document.dispatchEvent(
new CustomEvent('primer:card-submit', {
bubbles: true,
composed: true,
}),
);
});
// Update button state during processing
checkout.addEventListener('primer:state-change', (event) => {
const { isProcessing } = event.detail;
payButton.disabled = isProcessing;
payButton.textContent = isProcessing ? 'Processing...' : 'Pay Now';
});
Copy
Ask AI
@Composable
fun BrandedPayButton(
isEnabled: Boolean,
isLoading: Boolean,
onClick: () -> Unit,
) {
Button(
onClick = onClick,
enabled = isEnabled,
modifier = Modifier
.fillMaxWidth()
.height(52.dp),
shape = RoundedCornerShape(12.dp),
colors = ButtonDefaults.buttonColors(
containerColor = Color(0xFF6C5CE7),
disabledContainerColor = Color(0xFF6C5CE7).copy(alpha = 0.4f),
),
) {
if (isLoading) {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
color = Color.White,
strokeWidth = 2.dp,
)
} else {
Icon(
imageVector = Icons.Default.Lock,
contentDescription = null,
modifier = Modifier.size(18.dp),
)
Spacer(Modifier.width(8.dp))
Text(
text = "Complete Purchase",
style = MaterialTheme.typography.titleMedium,
)
}
}
}
Copy
Ask AI
struct CustomCardForm: View {
let scope: any PrimerCardFormScope
@State private var isValid = false
@State private var isLoading = false
var body: some View {
VStack(spacing: 16) {
scope.PrimerCardNumberField(label: "Card number", styling: nil)
scope.PrimerExpiryDateField(label: "Expiry", styling: nil)
scope.PrimerCvvField(label: "CVV", styling: nil)
Button(action: { scope.submit() }) {
HStack {
if isLoading {
ProgressView()
.tint(.white)
}
Text(isLoading ? "Processing..." : "Complete purchase")
}
.frame(maxWidth: .infinity, minHeight: 52)
}
.buttonStyle(.borderedProminent)
.tint(.purple)
.disabled(!isValid || isLoading)
}
.task {
for await state in scope.state {
isValid = state.isValid
isLoading = state.isLoading
}
}
}
}
Programmatic submission with amount
- Web
- Android
- iOS
Alternatively, you can call the submit method directly on the card form:
Copy
Ask AI
const cardForm = document.querySelector('primer-card-form');
const payButton = document.getElementById('my-pay-button');
payButton.addEventListener('click', async () => {
try {
await cardForm.submit();
} catch (error) {
console.error('Submission failed:', error);
}
});
Access the client session from the checkout state to show the amount on your button:
Copy
Ask AI
@Composable
fun CheckoutScreen(clientToken: String) {
val checkout = rememberPrimerCheckoutController(clientToken)
val state by checkout.state.collectAsStateWithLifecycle()
when (state) {
is PrimerCheckoutState.Loading -> CircularProgressIndicator()
is PrimerCheckoutState.Ready -> {
val clientSession = (state as PrimerCheckoutState.Ready).clientSession
val formattedAmount = checkout.formatAmount(clientSession.totalAmount ?: 0)
PrimerCheckoutSheet(
checkout = checkout,
onEvent = { /* ... */ },
cardForm = {
val controller = rememberCardFormController(checkout)
val formState by controller.state.collectAsStateWithLifecycle()
PrimerCardForm(
controller = controller,
submitButton = {
Button(
onClick = { controller.submit() },
enabled = formState.isFormValid && !formState.isLoading,
modifier = Modifier.fillMaxWidth(),
) {
Text("Pay $formattedAmount")
}
},
)
},
)
}
}
}
Customize the submit button text while keeping the default card form layout:
Copy
Ask AI
if let cardScope: PrimerCardFormScope = checkoutScope.getPaymentMethodScope(PrimerCardFormScope.self) {
cardScope.submitButtonText = "Pay $10.00"
// Or replace the button entirely:
cardScope.submitButton = {
AnyView(
Button("Pay securely") { }
.buttonStyle(.borderedProminent)
.frame(maxWidth: .infinity)
)
}
}
See also
Disable buttons during payment
Prevent double submission during payment processing
Build a custom card form
Step-by-step guide to building a fully custom card form