<primer-checkout> component and dispatch a custom event. On Android, override the submitButton slot in PrimerCardForm with a custom Composable. On iOS, override the submitButton slot in PrimerCardForm with a custom SwiftUI view.
Recipe
- Web
- Android
- iOS
<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>
document.getElementById('my-pay-button').addEventListener('click', () => {
document.dispatchEvent(
new CustomEvent('primer:card-submit', {
bubbles: true,
composed: true,
}),
);
});
@Composable
fun CheckoutScreen(clientToken: String) {
val checkout = rememberPrimerCheckoutController(clientToken)
val state by checkout.state.collectAsStateWithLifecycle()
LaunchedEffect(state) {
when (val s = state) {
is PrimerCheckoutState.Success -> { /* navigate */ }
is PrimerCheckoutState.Failure -> { /* log error */ }
else -> Unit
}
}
when (state) {
is PrimerCheckoutState.Loading -> CircularProgressIndicator()
is PrimerCheckoutState.Ready -> {
PrimerCheckoutSheet(
checkout = checkout,
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() },
)
},
)
},
)
}
}
}
struct CheckoutView: View {
@StateObject private var session: PrimerCheckoutSession
init(clientToken: String) {
_session = StateObject(
wrappedValue: PrimerCheckoutSession(clientToken: clientToken)
)
}
var body: some View {
ScrollView {
PrimerCardForm(submitButton: { formSession in
BrandedPayButton(session: formSession)
})
.padding()
}
.primerCheckoutSession(session) { state in
switch state {
case .success: break // navigate
case .failure: break // log error
default: break
}
}
}
}
struct BrandedPayButton: View {
@ObservedObject var session: PrimerCardFormSession
var body: some View {
Button(action: { session.submit() }) {
Text(session.state.isLoading ? "Processing..." : "Complete payment")
.frame(maxWidth: .infinity, minHeight: 50)
}
.buttonStyle(.borderedProminent)
.disabled(!session.state.isValid || session.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
- Pass a custom
submitButtonslot toPrimerCardForm; the other slots keep their default rendering - The slot receives the active
PrimerCardFormSession; observe it with@ObservedObject - Call
session.submit()from your custom button to trigger payment - Read
session.state.isValidandsession.state.isLoadingto control the enabled state and loading indicator
Variations
Button with loading state
- Web
- Android
- iOS
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';
});
@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,
)
}
}
}
struct BrandedPayButton: View {
@ObservedObject var session: PrimerCardFormSession
var body: some View {
Button(action: { session.submit() }) {
HStack {
if session.state.isLoading {
ProgressView()
.tint(.white)
}
Text(session.state.isLoading ? "Processing..." : "Complete purchase")
}
.frame(maxWidth: .infinity, minHeight: 52)
}
.buttonStyle(.borderedProminent)
.tint(.purple)
.disabled(!session.state.isValid || session.state.isLoading)
}
}
submitButton slot, leaving the card fields at their defaults:PrimerCardForm(submitButton: { session in
BrandedPayButton(session: session)
})
Programmatic submission with amount
- Web
- Android
- iOS
Alternatively, you can call the submit method directly on the card form:
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:
@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,
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")
}
},
)
},
)
}
}
}
Show the amount on your button by formatting the
totalAmount and currencyCode you already configured when you created the client session, then render the label through the submitButton slot. Keep the onCompletion closure reserved for the terminal .success/.failure/.dismissed outcomes — .ready is a lifecycle state and is never delivered there:struct CheckoutView: View {
@StateObject private var session: PrimerCheckoutSession
private let amountLabel: String
init(clientToken: String, totalAmount: Int, currencyCode: String) {
_session = StateObject(
wrappedValue: PrimerCheckoutSession(clientToken: clientToken)
)
amountLabel = "Pay \(Self.format(totalAmount, currencyCode))"
}
var body: some View {
ScrollView {
PrimerCardForm(submitButton: { formSession in
Button(action: { formSession.submit() }) {
Text(amountLabel)
.frame(maxWidth: .infinity, minHeight: 50)
}
.buttonStyle(.borderedProminent)
.disabled(!formSession.state.isValid || formSession.state.isLoading)
})
.padding()
}
.primerCheckoutSession(session) { state in
switch state {
case .success: break // navigate
case .failure: break // log error
default: break
}
}
}
private static func format(_ minorUnits: Int, _ currencyCode: String) -> String {
let formatter = NumberFormatter()
formatter.numberStyle = .currency
formatter.currencyCode = currencyCode
return formatter.string(from: NSNumber(value: Double(minorUnits) / 100)) ?? "\(minorUnits)"
}
}
totalAmount (Int, in minor units — for example 1000 = $10.00) and currencyCode are the values carried by the .ready lifecycle state; they match what you set when creating the client session. They are part of the lifecycle, not the onCompletion terminal callback — onCompletion fires exactly once and only with .success, .failure, or .dismissed. Format the amount for display with a NumberFormatter.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