> ## Documentation Index
> Fetch the complete documentation index at: https://primer.io/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# External submit button

> Trigger card form submission from a button outside the checkout.

Use your own submit button to trigger payment. On Web, place your button outside the `<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

> [tab] Web
>
> ```html theme={"dark"}
> <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>
> ```
>
> ```javascript theme={"dark"}
> document.getElementById('my-pay-button').addEventListener('click', () => {
>   document.dispatchEvent(
>     new CustomEvent('primer:card-submit', {
>       bubbles: true,
>       composed: true,
>     }),
>   );
> });
> ```

> [tab] Android
>
> ```kotlin theme={"dark"}
> @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() },
>                             )
>                         },
>                     )
>                 },
>             )
>         }
>     }
> }
> ```

> [tab] iOS
>
> ```swift theme={"dark"}
> 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

> [tab] Web
>
> 1. Place your custom button outside the `<primer-checkout>` component
>
> 2. Listen for click events on your button
>
> 3. Dispatch the `primer:card-submit` custom event to trigger form submission
>
> 4. The event bubbles up to the card form and initiates the payment
>
> > [!NOTE]
> >
> > The `bubbles: true` and `composed: true` options are required so the event can cross shadow DOM boundaries and reach the card form component.

> [tab] Android
>
> 1. Create a `PrimerCardFormController` to access form state
> 2. Pass a custom `submitButton` to `PrimerCardForm`
> 3. Use `formState.isFormValid` to control the enabled state and `formState.isLoading` for a loading indicator
> 4. Call `controller.submit()` to trigger payment

> [tab] iOS
>
> 1. Pass a custom `submitButton` slot to `PrimerCardForm`; the other slots keep their default rendering
> 2. The slot receives the active `PrimerCardFormSession`; observe it with `@ObservedObject`
> 3. Call `session.submit()` from your custom button to trigger payment
> 4. Read `session.state.isValid` and `session.state.isLoading` to control the enabled state and loading indicator

## Variations

### Button with loading state

> [tab] Web
>
> ```javascript theme={"dark"}
> 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';
> });
> ```

> [tab] Android
>
> ```kotlin theme={"dark"}
> @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,
>             )
>         }
>     }
> }
> ```

> [tab] iOS
>
> ```swift theme={"dark"}
> 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)
>   }
> }
> ```
>
> Pass it to the `submitButton` slot, leaving the card fields at their defaults:
>
> ```swift theme={"dark"}
> PrimerCardForm(submitButton: { session in
>   BrandedPayButton(session: session)
> })
> ```

### Programmatic submission with amount

> [tab] Web
>
> Alternatively, you can call the submit method directly on the card form:
>
> ```javascript theme={"dark"}
> 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);
>   }
> });
> ```

> [tab] Android
>
> Access the client session from the checkout state to show the amount on your button:
>
> ```kotlin theme={"dark"}
> @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")
>                             }
>                         },
>                     )
>                 },
>             )
>         }
>     }
> }
> ```

> [tab] iOS
>
> Show the amount on your button. Read `session.clientSession?.totalAmount`, add the card surcharge from `formSession.state.surchargeAmountRaw`, and pass the sum to `session.formatAmount(_:)`. Render the label through the `submitButton` slot:
>
> ```swift theme={"dark"}
> 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
>         Button(action: { formSession.submit() }) {
>           Text(amountLabel(formSession))
>             .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 func amountLabel(_ formSession: PrimerCardFormSession) -> String {
>     guard let total = session.clientSession?.totalAmount,
>           let formatted = session.formatAmount(total + (formSession.state.surchargeAmountRaw ?? 0))
>     else { return "Pay" }
>     return "Pay \(formatted)"
>   }
> }
> ```
>
> > [!NOTE]
> >
> > `totalAmount` is the amount you set when you created the client session, in minor units, for example `1000` for \$10.00. `formatAmount(_:)` uses the currency's own decimal digits, so it is also correct for currencies such as JPY. `clientSession` is `nil` until the session is ready, so the button reads "Pay" until then. `clientSession` is a snapshot from when the checkout became ready, and its `totalAmount` does not include the card surcharge. `formSession.state.surchargeAmountRaw` holds the surcharge for the detected card network in minor units. It is `nil` when no surcharge applies, so the button shows the plain total until the shopper enters a card number that has one.

## See also

> [card] [Disable buttons during payment](https://primer.io/docs/checkout/primer-checkout/guides-and-recipes/disable-buttons-during-payment.md)
>
> Prevent double submission during payment processing

> [card] [Build a custom card form](https://primer.io/docs/checkout/primer-checkout/guides-and-recipes/build-custom-card-form.md)
>
> Step-by-step guide to building a fully custom card form


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.