> ## 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

<Tabs>
  <Tab title="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>

  <Tab title="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>

  <Tab title="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)
      }
    }
    ```
  </Tab>
</Tabs>

## How it works

<Tabs>
  <Tab title="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

    <Info>
      The `bubbles: true` and `composed: true` options are required so the event can cross shadow DOM boundaries and reach the card form component.
    </Info>
  </Tab>

  <Tab title="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>

  <Tab title="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
  </Tab>
</Tabs>

## Variations

### Button with loading state

<Tabs>
  <Tab title="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>

  <Tab title="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>

  <Tab title="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)
    })
    ```
  </Tab>
</Tabs>

### Programmatic submission with amount

<Tabs>
  <Tab title="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>

  <Tab title="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>

  <Tab title="iOS">
    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:

    ```swift theme={"dark"}
    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)"
      }
    }
    ```

    <Note>
      `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`.
    </Note>
  </Tab>
</Tabs>

## See also

<CardGroup cols={2}>
  <Card title="Disable buttons during payment" icon="lock" href="/docs/checkout/primer-checkout/guides-and-recipes/disable-buttons-during-payment">
    Prevent double submission during payment processing
  </Card>

  <Card title="Build a custom card form" icon="credit-card" href="/docs/checkout/primer-checkout/guides-and-recipes/build-custom-card-form">
    Step-by-step guide to building a fully custom card form
  </Card>
</CardGroup>
