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

# Layout with Event Handling

> Learn how to implement fully custom layouts with events, dynamic rendering, and payment method filtering.

export const PhaseTimeline = ({phases}) => {
  const phaseColors = {
    1: {
      border: '#3b82f6',
      text: '#3b82f6'
    },
    2: {
      border: '#8b5cf6',
      text: '#8b5cf6'
    },
    3: {
      border: '#f59e0b',
      text: '#f59e0b'
    },
    4: {
      border: '#10b981',
      text: '#10b981'
    }
  };
  const directionStyles = {
    sdk: {
      color: '#3b82f6',
      label: 'SDK'
    },
    app: {
      color: '#10b981',
      label: 'APP'
    }
  };
  return <div style={{
    display: 'flex',
    flexDirection: 'column',
    gap: '0',
    position: 'relative',
    paddingLeft: '40px'
  }}>
      <div style={{
    position: 'absolute',
    left: '18px',
    top: '18px',
    bottom: '18px',
    width: '2px',
    backgroundColor: 'var(--tw-prose-hr, #e5e7eb)'
  }} />

      {phases.map((phase, phaseIndex) => {
    const colors = phaseColors[phaseIndex + 1] || phaseColors[4];
    const isOutcome = phase.branches && phase.branches.length > 0;
    return <div key={phase.name} style={{
      position: 'relative',
      paddingBottom: '24px'
    }}>
            <div className="phase-timeline-circle" style={{
      position: 'absolute',
      left: '-40px',
      top: '0',
      width: '36px',
      height: '36px',
      borderRadius: '50%',
      border: '2px solid ' + colors.border,
      display: 'flex',
      alignItems: 'center',
      justifyContent: 'center',
      fontWeight: '600',
      fontSize: '14px',
      color: colors.text,
      zIndex: 1
    }}>
              {phaseIndex + 1}
            </div>

            <div style={{
      marginLeft: '16px'
    }}>
              <div style={{
      fontWeight: '600',
      fontSize: '16px',
      color: 'var(--tw-prose-headings, #111827)',
      marginBottom: '4px'
    }}>
                {phase.name}
              </div>

              {phase.description && <div style={{
      fontSize: '14px',
      color: 'var(--tw-prose-body, #6b7280)',
      marginBottom: '16px'
    }}>
                  {phase.description}
                </div>}

              {!isOutcome && phase.events && <div style={{
      display: 'flex',
      flexDirection: 'column',
      gap: '8px',
      marginBottom: phase.action ? '12px' : '0'
    }}>
                  {phase.events.map(event => {
      const dir = directionStyles[event.direction] || directionStyles.sdk;
      return <div key={event.name} style={{
        display: 'flex',
        alignItems: 'center',
        justifyContent: 'space-between',
        padding: '10px 14px',
        border: '1px solid var(--tw-prose-hr, #e5e7eb)',
        borderRadius: '6px',
        gap: '12px'
      }}>
                        <div style={{
        display: 'flex',
        alignItems: 'center',
        gap: '10px'
      }}>
                          <span style={{
        display: 'inline-flex',
        alignItems: 'center',
        gap: '4px',
        padding: '2px 8px',
        border: '1px solid ' + dir.color,
        color: dir.color,
        borderRadius: '4px',
        fontSize: '11px',
        fontWeight: '600',
        whiteSpace: 'nowrap'
      }}>
                            {dir.label} {'→'}
                          </span>
                          <code style={{
        fontFamily: 'var(--font-mono, monospace)',
        fontSize: '13px',
        fontWeight: '500',
        color: 'var(--tw-prose-code, #111827)'
      }}>
                            {event.name}
                          </code>
                        </div>
                        {event.payload && <span style={{
        fontSize: '12px',
        color: 'var(--tw-prose-body, #9ca3af)',
        whiteSpace: 'nowrap'
      }}>
                            {event.payload}
                          </span>}
                      </div>;
    })}
                </div>}

              {isOutcome && <div style={{
      display: 'grid',
      gridTemplateColumns: 'repeat(2, 1fr)',
      gap: '12px',
      marginBottom: phase.action ? '12px' : '0'
    }}>
                  {phase.branches.map(branch => <div key={branch.label} style={{
      padding: '14px',
      border: '1px solid ' + (branch.type === 'success' ? '#10b981' : '#ef4444'),
      borderRadius: '6px'
    }}>
                      <div style={{
      fontWeight: '600',
      fontSize: '12px',
      textTransform: 'uppercase',
      letterSpacing: '0.05em',
      color: branch.type === 'success' ? '#10b981' : '#ef4444',
      marginBottom: '8px'
    }}>
                        {branch.label}
                      </div>
                      <div style={{
      display: 'flex',
      flexDirection: 'column',
      gap: '4px',
      fontFamily: 'var(--font-mono, monospace)',
      fontSize: '13px',
      color: 'var(--tw-prose-body, #374151)'
    }}>
                        {branch.events.map(e => <div key={e}>{e}</div>)}
                      </div>
                    </div>)}
                </div>}

              {phase.action && <div style={{
      padding: '10px 14px',
      borderLeft: '3px solid var(--tw-prose-hr, #e5e7eb)',
      fontSize: '13px',
      color: 'var(--tw-prose-body, #6b7280)'
    }}>
                  {phase.action}
                </div>}
            </div>
          </div>;
  })}
    </div>;
};

export const LAYOUT_EVENT_PHASES = [{
  name: 'Initialization',
  description: 'SDK loads and signals readiness.',
  events: [{
    name: 'primer:ready',
    direction: 'sdk',
    payload: 'PrimerJS instance'
  }],
  action: 'Register callbacks via PrimerJS'
}, {
  name: 'Payment Methods Discovery',
  description: 'SDK resolves which methods are available for this session.',
  events: [{
    name: 'primer:methods-update',
    direction: 'sdk',
    payload: 'available methods'
  }],
  action: 'Render payment method elements dynamically'
}, {
  name: 'State Changes',
  description: 'SDK communicates checkout state as the user progresses.',
  events: [{
    name: 'primer:state-change',
    direction: 'sdk',
    payload: 'isProcessing: true'
  }, {
    name: 'primer:state-change',
    direction: 'sdk',
    payload: 'isSuccessful: true'
  }],
  action: 'Show loading UI, then redirect or display success'
}];

This guide covers advanced layout customization techniques for when you need complete control over your checkout experience.

## Fully custom implementation

<Tabs>
  <Tab title="Web">
    For complete control, you can bypass `<primer-main>` entirely and provide your own implementation.

    Choose **one** error display approach:

    **Option A: Built-in error container**

    ```html theme={"dark"}
    <primer-checkout client-token="your-token">
      <div slot="main" id="custom-checkout">
        <div id="payment-methods">
          <primer-payment-method type="PAYMENT_CARD"></primer-payment-method>

          <!-- Pre-built component handles payment failure display automatically -->
          <primer-error-message-container></primer-error-message-container>
        </div>
      </div>
    </primer-checkout>
    ```

    **Option B: Custom error element**

    ```html theme={"dark"}
    <primer-checkout client-token="your-token">
      <div slot="main" id="custom-checkout">
        <div id="payment-methods">
          <primer-payment-method type="PAYMENT_CARD"></primer-payment-method>

          <!-- Your own error element — requires event handling in JavaScript -->
          <div id="my-custom-error" class="custom-error-message"></div>
        </div>
      </div>
    </primer-checkout>
    ```

    <Warning>
      **Implementation responsibility**

      When using this approach:

      * You must handle state management yourself through events
      * You have complete freedom over the layout and user flow
      * You're responsible for showing/hiding appropriate content based on checkout state
      * You need to handle payment failure display, either with the `<primer-error-message-container>` component or by implementing custom error handling with events
    </Warning>
  </Tab>

  <Tab title="Android">
    Use `PrimerCheckoutHost` with a `content` lambda for full layout control. You compose child components freely inside the host.

    ```kotlin theme={"dark"}
    val state by checkout.state.collectAsStateWithLifecycle()

    PrimerCheckoutHost(checkout, onEvent = ::handleEvent) {
        if (state is PrimerCheckoutState.Ready) {
            Column {
                val vaultedController = rememberVaultedPaymentMethodsController(checkout)
                PrimerVaultedPaymentMethods(vaultedController)

                val paymentMethodsController = rememberPaymentMethodsController(checkout)
                PrimerPaymentMethods(paymentMethodsController)

                val cardFormController = rememberCardFormController(checkout)
                PrimerCardForm(controller = cardFormController)
            }
        }
    }
    ```

    <Info>
      `PrimerCheckoutHost` does not have built-in error or success slots. Handle all outcomes via the `onEvent` callback:

      ```kotlin theme={"dark"}
      fun handleEvent(event: PrimerCheckoutEvent) {
          when (event) {
              is PrimerCheckoutEvent.Success -> showSuccessScreen(event.checkoutData)
              is PrimerCheckoutEvent.Failure -> showErrorScreen(event.error)
          }
      }
      ```

      3DS and redirect flows are handled automatically via an internal overlay inside the host.
    </Info>
  </Tab>

  <Tab title="iOS">
    For full layout control, replace entire screens with custom SwiftUI views via the scope closure. The scope provides access to state, actions, and SDK-managed fields.

    ```swift theme={"dark"}
    PrimerCheckout(
      clientToken: clientToken,
      scope: { checkoutScope in
        // Custom splash and error screens
        checkoutScope.splashScreen = { AnyView(MySplashScreen()) }
        checkoutScope.errorScreen = { msg in AnyView(MyErrorScreen(message: msg)) }

        // Full custom card form screen
        if let cardScope: PrimerCardFormScope = checkoutScope.getPaymentMethodScope(PrimerCardFormScope.self) {
          cardScope.screen = { scope in
            AnyView(MyCardForm(scope: scope))
          }
        }
      },
      onCompletion: { state in
        if case .failure(let error) = state {
          print("Payment failed: \(error.errorDescription ?? "")")
        }
      }
    )
    ```

    <Warning>
      When replacing a full screen, your view is responsible for all UI elements. The SDK still handles payment logic, validation, and state management, but you must call the appropriate scope methods (e.g., `submit()`, `onBack()`) from your custom view.
    </Warning>
  </Tab>
</Tabs>

## Events

When implementing a custom layout, you need to listen for events to manage checkout states.

For comprehensive information on all available events, event payloads, and best practices, see the [Events Guide](/checkout/primer-checkout/configuration/events).

<Tabs>
  <Tab title="Web">
    ```javascript theme={"dark"}
    document
      .querySelector('primer-checkout')
      .addEventListener('primer:state-change', (event) => {
        const state = event.detail;

        if (state.isProcessing) {
          // Show loading indicator
        } else if (state.isSuccessful) {
          // Show success message
        } else if (state.primerJsError || state.paymentFailure) {
          // Show error message
          const errorMessage =
            state.primerJsError?.message || state.paymentFailure?.message;
          // Display error to user
        }
      });
    ```

    <Accordion title="Key events to listen for">
      * `primer:state-change` - Fired when checkout state changes
      * `primer:methods-update` - Fired when available payment methods are loaded
      * `primer:ready` - Fired when the SDK is ready
    </Accordion>
  </Tab>

  <Tab title="Android">
    ```kotlin theme={"dark"}
    PrimerCheckoutHost(
        checkout = checkout,
        onEvent = { event ->
            when (event) {
                is PrimerCheckoutEvent.Success -> {
                    navController.navigate("confirmation/${event.checkoutData.payment.id}")
                }
                is PrimerCheckoutEvent.Failure -> {
                    showError(event.error.description)
                }
            }
        },
    ) {
        // Your custom layout
    }
    ```

    Android uses a single `onEvent` callback instead of multiple event listeners. State updates are delivered reactively via `StateFlow` on individual controllers.
  </Tab>

  <Tab title="iOS">
    On iOS, observe state changes via `AsyncStream` on scope objects:

    ```swift theme={"dark"}
    Task {
      for await state in cardFormScope.state {
        if state.isValid {
          // Enable submit button
        }
        if state.isLoading {
          // Show loading indicator
        }
      }
    }
    ```

    Payment outcomes are reported through the `onCompletion` callback:

    ```swift theme={"dark"}
    PrimerCheckout(
      clientToken: clientToken,
      onCompletion: { state in
        switch state {
        case .success(let data):
          print("Payment succeeded: \(data)")
        case .failure(let error):
          print("Payment failed: \(error.errorId)")
        }
      }
    )
    ```
  </Tab>
</Tabs>

<PhaseTimeline phases={LAYOUT_EVENT_PHASES} />

## Configuring payment methods

<Tabs>
  <Tab title="Web">
    When customizing the payment method layout, you can include specific payment methods:

    ```html theme={"dark"}
    <div slot="payments">
      <primer-payment-method type="PAYMENT_CARD"></primer-payment-method>
      <primer-payment-method type="PAYPAL"></primer-payment-method>
    </div>
    ```

    The `type` attribute specifies which payment method to display. If a payment method isn't available in your Dashboard configuration, it simply won't render.

    ### Payment method filtering with `include`, `exclude` and `type`

    The `primer-payment-method-container` component provides a declarative way to organize payment methods:

    ```html theme={"dark"}
    <!-- Sectioned layout example -->
    <div slot="payments">
      <!-- Quick pay options -->
      <primer-payment-method-container
        include="APPLE_PAY,GOOGLE_PAY"
      ></primer-payment-method-container>

      <!-- Alternative methods -->
      <primer-payment-method-container
        exclude="PAYMENT_CARD,APPLE_PAY,GOOGLE_PAY"
      ></primer-payment-method-container>

      <!-- Card form -->
      <primer-payment-method type="PAYMENT_CARD"></primer-payment-method>
    </div>
    ```

    This approach automatically filters available payment methods without requiring event listeners or manual state management. See the [Payment Method Container SDK Reference documentation](/sdk/primer-checkout-web/components/primer-payment-method-container) for complete usage guide.

    ### Dynamic rendering with events

    You can also dynamically render payment methods by listening to the `primer:methods-update` event:

    <Accordion title="Example: Dynamic payment method rendering">
      ```javascript theme={"dark"}
      checkout.addEventListener('primer:methods-update', (event) => {
        const availableMethods = event.detail.toArray();
        const container = document.getElementById('payment-methods');

        availableMethods.forEach((method) => {
          const element = document.createElement('primer-payment-method');
          element.setAttribute('type', method.type);
          container.appendChild(element);
        });
      });
      ```
    </Accordion>

    This approach ensures you only display payment methods that are actually available.
  </Tab>

  <Tab title="Android">
    Use `rememberPaymentMethodsController()` to access the available payment methods as a reactive `StateFlow`:

    ```kotlin theme={"dark"}
    val controller = rememberPaymentMethodsController(checkout)
    val methods by controller.methods.collectAsStateWithLifecycle()

    LazyColumn {
        items(methods) { method ->
            PaymentMethodCard(
                name = method.paymentMethodName ?: method.paymentMethodType,
                onClick = { controller.select(method) },
            )
        }
    }
    ```

    ### Client-side filtering

    There is no SDK API to filter or sort payment methods server-side. Filter the `methods` list client-side before rendering:

    ```kotlin theme={"dark"}
    val controller = rememberPaymentMethodsController(checkout)
    val methods by controller.methods.collectAsStateWithLifecycle()

    val filtered = methods.filter {
        it.paymentMethodType in listOf("PAYMENT_CARD", "PAYPAL")
    }
    ```

    The `StateFlow` updates automatically when `checkout.refresh()` is called.
  </Tab>

  <Tab title="iOS">
    Use `PrimerPaymentMethodSelectionScope.state` to access the list of available payment methods as an `AsyncStream`:

    ```swift theme={"dark"}
    struct CustomPaymentMethodList: View {
      let scope: PrimerPaymentMethodSelectionScope
      @State private var methods: [CheckoutPaymentMethod] = []

      var body: some View {
        VStack {
          ForEach(methods, id: \.id) { method in
            Button { scope.onPaymentMethodSelected(paymentMethod: method) } label: {
              HStack {
                if let icon = method.icon {
                  Image(uiImage: icon).resizable().frame(width: 40, height: 28)
                }
                Text(method.name)
                Spacer()
              }
              .padding()
            }
          }
        }
        .task {
          for await state in scope.state {
            methods = state.paymentMethods
          }
        }
      }
    }
    ```

    Filter payment methods client-side before rendering:

    ```swift theme={"dark"}
    let cardAndPaypal = methods.filter { ["PAYMENT_CARD", "PAYPAL"].contains($0.type) }
    ```
  </Tab>
</Tabs>

## Avoiding duplicate card forms

<Tabs>
  <Tab title="Web">
    <Warning title="Common mistake">
      When customizing your checkout layout, be careful not to render duplicate card forms. This commonly happens when:

      1. You create a custom card form using `<primer-card-form>`
      2. You also include `<primer-payment-method type="PAYMENT_CARD">` in your layout
    </Warning>

    ```html theme={"dark"}
    <!-- INCORRECT: Will result in duplicate card forms -->
    <div slot="payments">
      <!-- Custom card form -->
      <primer-card-form>
        <!-- Custom card form content -->
      </primer-card-form>

      <!-- This will render ANOTHER card form -->
      <primer-payment-method type="PAYMENT_CARD"></primer-payment-method>
    </div>
    ```

    If you're using a custom card form implementation, you should **not** include the `PAYMENT_CARD` payment method in your layout.

    <Accordion title="Filtering to avoid duplicate card forms">
      **Important:** If you're using a custom card form, you should filter out the `PAYMENT_CARD` type to avoid duplicate card forms:

      ```javascript theme={"dark"}
      checkout.addEventListener('primer:methods-update', (event) => {
        let availableMethods = event.detail.toArray();
        const container = document.getElementById('payment-methods');

        if (document.querySelector('primer-card-form')) {
          availableMethods = availableMethods.filter(
            (method) => method.type !== 'PAYMENT_CARD',
          );
        }

        availableMethods.forEach((method) => {
          const element = document.createElement('primer-payment-method');
          element.setAttribute('type', method.type);
          container.appendChild(element);
        });
      });
      ```
    </Accordion>
  </Tab>

  <Tab title="Android">
    On Android, this issue does not apply. `PrimerCardForm` and `PrimerPaymentMethods` are separate composables that don't conflict. When using `PrimerCheckoutHost`, you compose them independently:

    ```kotlin theme={"dark"}
    PrimerCheckoutHost(checkout, onEvent = ::handleEvent) {
        Column {
            PrimerCardForm(controller = rememberCardFormController(checkout))
            PrimerPaymentMethods(rememberPaymentMethodsController(checkout))
        }
    }
    ```

    If you want to exclude card payments from `PrimerPaymentMethods` (because you render `PrimerCardForm` separately), filter the methods list client-side:

    ```kotlin theme={"dark"}
    val controller = rememberPaymentMethodsController(checkout)
    val methods by controller.methods.collectAsStateWithLifecycle()
    val nonCardMethods = methods.filter { it.paymentMethodType != "PAYMENT_CARD" }
    ```
  </Tab>

  <Tab title="iOS">
    On iOS, this issue does not apply. The card form scope and the payment method selection scope are separate. If you replace the card form screen via `cardScope.screen`, it does not duplicate the card entry in the payment method selection list.

    If you want to exclude card payments from the selection list when rendering a separate card form, filter the methods list:

    ```swift theme={"dark"}
    let nonCardMethods = methods.filter { $0.type != "PAYMENT_CARD" }
    ```
  </Tab>
</Tabs>

## Best practices

<Tip title="Best practices summary">
  1. **Listen for relevant events** - Handle checkout state through event listeners (Web) or `onEvent` callbacks (Android)
  2. **Design responsively** - Ensure your layout works on all device sizes
  3. **Test thoroughly** - Validate behavior across different payment methods and scenarios
  4. **Prevent component flash** - Use CSS or JavaScript techniques to hide content until components are defined (Web), or observe `PrimerCheckoutState.Ready` before rendering (Android)
  5. **Handle payment failures** - Either use the `<primer-error-message-container>` component (Web) or handle `PrimerCheckoutEvent.Failure` in `onEvent` (Android)
</Tip>

## See also

<CardGroup cols={2}>
  <Card title="Error handling" icon="triangle-exclamation" href="/checkout/primer-checkout/build-your-ui/error-handling">
    Handle payment failures and display error messages
  </Card>

  <Card title="Events guide" icon="bolt" href="/checkout/primer-checkout/configuration/events">
    Complete reference for all checkout events
  </Card>
</CardGroup>
