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

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

        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() },
                                )
                            },
                        )
                    },
                )
            }
        }
    }
    ```
  </Tab>

  <Tab title="iOS">
    ```swift theme={"dark"}
    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
          }
        }
      }
    }
    ```
  </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. Access `PrimerCardFormScope` via `getPaymentMethodScope()` in the scope closure
    2. Replace the `screen` property with your own layout using SDK-managed fields
    3. Call `scope.submit()` from your custom button to trigger payment
    4. Observe `scope.state` to monitor `isValid` and `isLoading` for button state
  </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 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
          }
        }
      }
    }
    ```
  </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,
                    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")
                                }
                            },
                        )
                    },
                )
            }
        }
    }
    ```
  </Tab>

  <Tab title="iOS">
    Customize the submit button text while keeping the default card form layout:

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

## See also

<CardGroup cols={2}>
  <Card title="Disable buttons during payment" icon="lock" href="/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="/checkout/primer-checkout/guides-and-recipes/build-custom-card-form">
    Step-by-step guide to building a fully custom card form
  </Card>
</CardGroup>
