> ## 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) {
        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. React to outcomes by observing `checkout.state`:

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

      LaunchedEffect(state) {
          when (val s = state) {
              is PrimerCheckoutState.Success -> showSuccessScreen(s.checkoutData)
              is PrimerCheckoutState.Failure -> showErrorScreen(s.error)
              else -> Unit
          }
      }
      ```

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

  <Tab title="iOS">
    For full layout control, hold a `PrimerCheckoutSession` and compose the SDK's views inside your own layout, wiring them in with the `.primerCheckoutSession(_:onCompletion:)` modifier. The session injects the per-feature sessions into the environment, so views like `PrimerCardForm` and `PrimerPaymentMethods` resolve their state automatically.

    ```swift theme={"dark"}
    struct CustomCheckout: View {
      @StateObject private var session: PrimerCheckoutSession

      init(clientToken: String) {
        _session = StateObject(
          wrappedValue: PrimerCheckoutSession(clientToken: clientToken)
        )
      }

      var body: some View {
        ScrollView {
          VStack(spacing: 24) {
            PrimerVaultedPaymentMethods()
            PrimerPaymentMethods()
            PrimerCardForm()
          }
          .padding()
        }
        .primerCheckoutSession(session) { state in
          if case .failure(let error) = state {
            print("Payment failed: \(error.localizedDescription)")
          }
        }
      }
    }
    ```

    <Info>
      `PrimerCheckoutSession` does not have built-in error or success screens — handle all outcomes through the modifier's `onCompletion` closure, which delivers the terminal [`PrimerCheckoutState`](/docs/sdk/ios-checkout/v3.0.0-beta/api/primer-checkout-state). 3DS challenges and redirect flows are handled automatically via internal overlays once the session is `.ready`. For the prebuilt, fully managed screens instead, use [`PrimerCheckout`](/docs/sdk/ios-checkout/v3.0.0-beta/api/primer-checkout).
    </Info>

    <Warning>
      When you compose your own layout, your views are responsible for all UI elements. The SDK still handles payment logic, validation, and state management, but you drive the UI through each view's `@ViewBuilder` slots and call the session methods (for example `session.submit()` on the [`PrimerCardFormSession`](/docs/sdk/ios-checkout/v3.0.0-beta/api/card-form/card-form-session) handed to a slot).
    </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](/docs/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"}
    val state by checkout.state.collectAsStateWithLifecycle()

    LaunchedEffect(state) {
        when (val s = state) {
            is PrimerCheckoutState.Success -> {
                navController.navigate("confirmation/${s.checkoutData.payment.id}")
            }
            is PrimerCheckoutState.Failure -> {
                showError(s.error.description)
            }
            else -> Unit
        }
    }

    PrimerCheckoutHost(checkout = checkout) {
        // Your custom layout
    }
    ```

    Android delivers payment outcomes as terminal cases of the single `PrimerCheckoutState` flow (`Success`, `Failure`), observed via `checkout.state`. All state updates are delivered reactively via `StateFlow`.
  </Tab>

  <Tab title="iOS">
    On iOS, observe state changes reactively by reading the `@Published` `state` on the session a slot hands you. Mark the session as `@ObservedObject` so SwiftUI re-renders when it changes:

    ```swift theme={"dark"}
    PrimerCardForm(submitButton: { session in
      Button("Pay") { session.submit() }
        .disabled(!session.state.isValid || session.state.isLoading)
    })
    ```

    Payment outcomes are reported through the `onCompletion` closure on the `.primerCheckoutSession(_:onCompletion:)` modifier, which delivers the terminal [`PrimerCheckoutState`](/docs/sdk/ios-checkout/v3.0.0-beta/api/primer-checkout-state):

    ```swift theme={"dark"}
    .primerCheckoutSession(session) { state in
      switch state {
      case .success(let result):
        print("Payment succeeded: \(result.paymentId)")
      case .failure(let error):
        print("Payment failed: \(error.localizedDescription)")
      case .dismissed:
        print("Checkout dismissed")
      default:
        break
      }
    }
    ```

    iOS delivers a single terminal `PrimerCheckoutState` to `onCompletion` instead of multiple event listeners. State updates are delivered reactively via the `@Published` `state` on each session (`PrimerCardFormSession`, `PrimerSelectionSession`).
  </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](/docs/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">
    Override the `method` slot on `PrimerPaymentMethods` to render your own row. The slot receives a `CheckoutPaymentMethod` and an `onSelect` closure — call `onSelect` to start that method's flow. The view resolves its [`PrimerSelectionSession`](/docs/sdk/ios-checkout/v3.0.0-beta/api/payment-methods/payment-methods-session) from the environment and renders one row per available method:

    ```swift theme={"dark"}
    PrimerPaymentMethods(
      method: { paymentMethod, onSelect in
        Button(action: onSelect) {
          HStack {
            if let icon = paymentMethod.icon {
              Image(uiImage: icon)
                .resizable()
                .scaledToFit()
                .frame(width: 40, height: 28)
            }
            Text(paymentMethod.buttonText ?? paymentMethod.name)
            Spacer()
          }
          .padding()
        }
        .buttonStyle(.plain)
      }
    )
    ```

    ### Client-side filtering

    There is no SDK API to filter or sort payment methods server-side. To build a fully custom list, read the available methods from `session.state.paymentMethods` and filter them client-side before rendering, calling `session.select(_:)` on the [`PrimerSelectionSession`](/docs/sdk/ios-checkout/v3.0.0-beta/api/payment-methods/payment-methods-session) when a row is tapped:

    ```swift theme={"dark"}
    struct CustomPaymentMethodList: View {
      @ObservedObject var session: PrimerSelectionSession

      var body: some View {
        let methods = session.state.paymentMethods.filter {
          ["PAYMENT_CARD", "PAYPAL"].contains($0.type)
        }
        VStack {
          ForEach(methods) { method in
            Button { session.select(method) } label: {
              HStack {
                if let icon = method.icon {
                  Image(uiImage: icon).resizable().scaledToFit().frame(width: 40, height: 28)
                }
                Text(method.name)
                Spacer()
              }
              .padding()
            }
          }
        }
      }
    }
    ```

    `state` is `@Published`, so the list updates automatically when the available methods change (for example after the [`PrimerCheckoutSession`](/docs/sdk/ios-checkout/v3.0.0-beta/api/primer-checkout-session) is refreshed via its `refresh()` method).
  </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) {
        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. `PrimerCardForm` and `PrimerPaymentMethods` are separate views that don't conflict. When composing your own layout under `.primerCheckoutSession(_:)`, you place them independently:

    ```swift theme={"dark"}
    ScrollView {
      VStack(spacing: 24) {
        PrimerCardForm()
        PrimerPaymentMethods()
      }
    }
    .primerCheckoutSession(session) { state in
      handle(state)
    }
    ```

    If you want to exclude card payments from `PrimerPaymentMethods` (because you render `PrimerCardForm` separately), filter `session.state.paymentMethods` client-side when building a custom `method` slot:

    ```swift theme={"dark"}
    let nonCardMethods = session.state.paymentMethods.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 by observing `checkout.state` (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 match on `PrimerCheckoutState.Failure` from `checkout.state` (Android)
</Tip>

## See also

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

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