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

# Build a custom card form

> Step-by-step guide to creating and customizing your own card payment form with Primer Checkout.

export const CARD_FORM_HIERARCHY = [{
  name: 'primer-checkout',
  slots: ['main'],
  children: [{
    name: 'primer-main',
    goesInto: 'main',
    children: [{
      name: 'primer-payment-method',
      note: 'type="PAYMENT_CARD"',
      children: [{
        name: 'primer-card-form',
        slots: ['card-form-content'],
        children: [{
          name: 'primer-input-card-number',
          goesInto: 'card-form-content'
        }, {
          name: 'primer-input-card-expiry',
          goesInto: 'card-form-content'
        }, {
          name: 'primer-input-cvv',
          goesInto: 'card-form-content'
        }, {
          name: 'primer-input-card-holder-name',
          goesInto: 'card-form-content'
        }, {
          name: 'primer-billing-address',
          goesInto: 'card-form-content'
        }, {
          name: 'primer-card-form-submit',
          goesInto: 'card-form-content'
        }, {
          name: 'primer-error-message',
          goesInto: 'card-form-content',
          note: 'form errors'
        }]
      }]
    }]
  }]
}];

export const ComponentTree = ({nodes, showLegend = true}) => {
  const renderNode = (node, depth = 0, isLast = true, prefix = '') => {
    const connector = depth === 0 ? '' : isLast ? '\u2514\u2500\u2500' : '\u251c\u2500\u2500';
    const childPrefix = prefix + (depth === 0 ? '' : isLast ? '    ' : '\u2502   ');
    const hasSlots = node.slots && node.slots.length > 0;
    const goesInto = node.goesInto;
    return <div key={node.name + depth} style={{
      fontFamily: 'var(--font-mono, monospace)',
      fontSize: '14px',
      lineHeight: '2'
    }}>
        <div style={{
      display: 'flex',
      alignItems: 'center',
      whiteSpace: 'nowrap'
    }}>
          <span className="component-tree-connector">{prefix}{connector}</span>
          {depth > 0 && <span style={{
      width: '8px'
    }} />}
          <span className="component-tree-name">{node.name}</span>
          {goesInto && <span className="component-tree-goes-into">
              {'\u2192'} {goesInto}
            </span>}
          {hasSlots && node.slots.map(slotName => <span key={slotName} className="component-tree-slot">
              slot: {slotName}
            </span>)}
          {node.note && <span className="component-tree-note">
              {node.note}
            </span>}
        </div>
        {node.children && node.children.map((child, i) => renderNode(child, depth + 1, i === node.children.length - 1, childPrefix))}
      </div>;
  };
  return <div className="component-tree-container">
      <div>
        {nodes.map((node, i) => renderNode(node, 0, i === nodes.length - 1, ''))}
      </div>
      {showLegend && <div className="component-tree-legend">
          <div style={{
    display: 'flex',
    alignItems: 'center',
    gap: '6px'
  }}>
            <span className="component-tree-slot">
              slot: name
            </span>
            <span>Exposes a customizable slot</span>
          </div>
          <div style={{
    display: 'flex',
    alignItems: 'center',
    gap: '6px'
  }}>
            <span className="component-tree-goes-into">
              {'\u2192'} name
            </span>
            <span>Default content, replaced when you customize the slot</span>
          </div>
        </div>}
    </div>;
};

This tutorial walks you through building a custom card form with Primer Checkout. You'll learn how to customize the layout, style the inputs, handle events, and avoid common pitfalls.

## Prerequisites

Before starting, make sure you have:

<Tabs>
  <Tab title="Web">
    * [Installed Primer Checkout](/docs/checkout/primer-checkout/installation)
    * [Created your first payment](/docs/checkout/primer-checkout/first-payment)
    * Familiarity with [layout customization](/docs/checkout/primer-checkout/build-your-ui/layout-customization)
  </Tab>

  <Tab title="Android">
    * A working checkout integration ([First Payment](/docs/checkout/primer-checkout/first-payment))
    * Familiarity with the [controller pattern](/docs/checkout/primer-checkout/core-concepts)
    * Understanding of [PrimerCheckoutHost](/docs/checkout/primer-checkout/build-your-ui/layout-customization) for inline integration
  </Tab>

  <Tab title="iOS">
    * A working checkout integration ([First Payment](/docs/checkout/primer-checkout/first-payment))
    * Familiarity with [layout customization](/docs/checkout/primer-checkout/build-your-ui/layout-customization)
    * Understanding of `PrimerCardForm` and its `@ViewBuilder` slots for card form control
  </Tab>
</Tabs>

## Understanding the card form architecture

<Tabs>
  <Tab title="Web">
    The `<primer-card-form>` component provides a customizable card payment interface with PCI-compliant hosted inputs. Here's how the components relate to each other:

    <ComponentTree nodes={CARD_FORM_HIERARCHY} />

    <Info>
      **Security by design**

      The card input components (`primer-input-card-number`, `primer-input-card-expiry`, `primer-input-cvv`) render secure iframes that isolate sensitive card data. This means:

      * Card data never touches your page's DOM
      * Your integration remains PCI-compliant
      * Styling is applied through CSS variables that are passed to the iframe
    </Info>

    ### Key components

    | Component                       | Purpose                                             |
    | ------------------------------- | --------------------------------------------------- |
    | `primer-card-form`              | Container that provides context for all card inputs |
    | `primer-input-card-number`      | Secure hosted input for card number                 |
    | `primer-input-card-expiry`      | Secure hosted input for expiry date                 |
    | `primer-input-cvv`              | Secure hosted input for CVV                         |
    | `primer-input-card-holder-name` | Input for cardholder name (optional)                |
    | `primer-card-form-submit`       | Submit button with built-in loading states          |
  </Tab>

  <Tab title="Android">
    The `PrimerCardFormController` gives you full control over the card form. You create the controller, observe its state, and build your own UI with custom `TextField` inputs bound to the controller.

    ### Key state properties

    | Property      | Type                                  | Description                                 |
    | ------------- | ------------------------------------- | ------------------------------------------- |
    | `data`        | `Map<PrimerInputElementType, String>` | Current field values                        |
    | `fieldErrors` | `List<SyncValidationError>?`          | Validation errors per field                 |
    | `isFormValid` | `Boolean`                             | Whether all required fields pass validation |
    | `isLoading`   | `Boolean`                             | Whether a submission is in progress         |
  </Tab>

  <Tab title="iOS">
    `PrimerCardForm` is a SwiftUI view that gives you full control over the card form through three `@ViewBuilder` section slots: `cardDetails`, `billingAddress`, and `submitButton`. Each slot receives a `PrimerCardFormSession`, which publishes the form `state` and exposes the field-update and `submit()` methods. Compose your layout from the `CardFormDefaults` per-field building blocks (`cardNumber`, `expiryDate`, `cvv`, `cardholderName`). Primer handles validation, formatting, and PCI compliance.

    ### Key state properties

    The session exposes its state through `session.state`, a `PrimerCardFormState`:

    | Property      | Type           | Description                                            |
    | ------------- | -------------- | ------------------------------------------------------ |
    | `isValid`     | `Bool`         | Whether all required fields pass validation            |
    | `isLoading`   | `Bool`         | Whether a submission is in progress                    |
    | `fieldErrors` | `[FieldError]` | Validation errors per field                            |
    | `data`        | `FormData`     | Current field values keyed by `PrimerInputElementType` |
  </Tab>
</Tabs>

## Step 1: Create the card form

<Tabs>
  <Tab title="Web">
    Start by creating a custom card form using the `card-form-content` slot:

    ```html theme={"dark"}
    <primer-checkout client-token="your-client-token">
      <primer-main slot="main">
        <div slot="payments">
          <primer-card-form>
            <div slot="card-form-content">
              <primer-input-card-number></primer-input-card-number>
              <primer-input-card-expiry></primer-input-card-expiry>
              <primer-input-cvv></primer-input-cvv>
              <primer-input-card-holder-name></primer-input-card-holder-name>
              <primer-card-form-submit>Pay Now</primer-card-form-submit>
            </div>
          </primer-card-form>
        </div>
      </primer-main>
    </primer-checkout>
    ```

    <Warning>
      **Component hierarchy matters**

      All card input components must be nested inside `<primer-card-form>`. Placing them outside breaks the context connection and the form won't work.

      ```html theme={"dark"}
      <!-- WRONG: Inputs outside primer-card-form -->
      <primer-card-form></primer-card-form>
      <primer-input-card-number></primer-input-card-number>

      <!-- CORRECT: Inputs inside primer-card-form -->
      <primer-card-form>
        <div slot="card-form-content">
          <primer-input-card-number></primer-input-card-number>
        </div>
      </primer-card-form>
      ```
    </Warning>
  </Tab>

  <Tab title="Android">
    Create a `PrimerCardFormController` inside a `PrimerCheckoutHost`. The controller manages field values, validation, and submission.

    ```kotlin theme={"dark"}
    @Composable
    fun CustomCardFormScreen(clientToken: String) {
        val checkout = rememberPrimerCheckoutController(clientToken)
        val checkoutState by checkout.state.collectAsStateWithLifecycle()

        LaunchedEffect(checkoutState) {
            when (val s = checkoutState) {
                is PrimerCheckoutState.Success -> { /* navigate */ }
                is PrimerCheckoutState.Failure -> { /* log error */ }
                else -> Unit
            }
        }

        when (checkoutState) {
            is PrimerCheckoutState.Loading -> CircularProgressIndicator()
            is PrimerCheckoutState.Ready -> {
                PrimerCheckoutHost(checkout = checkout) {
                    val cardFormController = rememberCardFormController(checkout)
                    CustomCardForm(cardFormController)
                }
            }
        }
    }
    ```
  </Tab>

  <Tab title="iOS">
    Hold a `PrimerCheckoutSession` as a `@StateObject` and place a `PrimerCardForm` inside a view that has the `.primerCheckoutSession(_:onCompletion:)` modifier applied. `PrimerCardForm` resolves its `PrimerCardFormSession` from the environment, so it works inline anywhere under the modifier.

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

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

      var body: some View {
        ScrollView {
          PrimerCardForm()
        }
        .primerCheckoutSession(session) { state in
          // Handle the terminal PrimerCheckoutState
        }
      }
    }
    ```

    <Warning>
      **Session resolution matters**

      `PrimerCardForm` and its slots resolve the active `PrimerCardFormSession` from the environment. Place the form inside a view that has the `.primerCheckoutSession(_:onCompletion:)` modifier applied, or the form renders an empty placeholder.
    </Warning>
  </Tab>
</Tabs>

## Step 2: Customize the layout

<Tabs>
  <Tab title="Web">
    ### Vertical layout (default)

    Stack inputs vertically for a clean, mobile-friendly form:

    ```html theme={"dark"}
    <primer-card-form>
      <div slot="card-form-content" class="card-form-vertical">
        <primer-input-card-number></primer-input-card-number>
        <primer-input-card-expiry></primer-input-card-expiry>
        <primer-input-cvv></primer-input-cvv>
        <primer-input-card-holder-name></primer-input-card-holder-name>
        <primer-card-form-submit>Pay Now</primer-card-form-submit>
      </div>
    </primer-card-form>
    ```

    ```css theme={"dark"}
    .card-form-vertical {
      display: flex;
      flex-direction: column;
      gap: var(--primer-space-small);
    }
    ```

    ### Grouped layout

    Place expiry and CVV side by side:

    ```html theme={"dark"}
    <primer-card-form>
      <div slot="card-form-content" class="card-form-grouped">
        <primer-input-card-number></primer-input-card-number>
        <div class="row">
          <primer-input-card-expiry></primer-input-card-expiry>
          <primer-input-cvv></primer-input-cvv>
        </div>
        <primer-input-card-holder-name></primer-input-card-holder-name>
        <primer-card-form-submit>Pay Now</primer-card-form-submit>
      </div>
    </primer-card-form>
    ```

    ```css theme={"dark"}
    .card-form-grouped {
      display: flex;
      flex-direction: column;
      gap: var(--primer-space-small);
    }

    .card-form-grouped .row {
      display: flex;
      gap: var(--primer-space-small);
    }

    .card-form-grouped .row > * {
      flex: 1;
    }
    ```

    ### Responsive layout

    Adapt the layout based on screen size:

    ```css theme={"dark"}
    .card-form-responsive {
      display: flex;
      flex-direction: column;
      gap: var(--primer-space-small);
    }

    .card-form-responsive .row {
      display: flex;
      flex-direction: column;
      gap: var(--primer-space-small);
    }

    @media (min-width: 480px) {
      .card-form-responsive .row {
        flex-direction: row;
      }

      .card-form-responsive .row > * {
        flex: 1;
      }
    }
    ```
  </Tab>

  <Tab title="Android">
    Observe form state and bind each `OutlinedTextField` to the controller's update methods. The controller handles formatting (card number grouping, expiry date slashes) internally.

    ```kotlin theme={"dark"}
    @Composable
    fun CustomCardForm(controller: PrimerCardFormController) {
        val formState by controller.state.collectAsStateWithLifecycle()

        Column(
            modifier = Modifier.padding(16.dp),
            verticalArrangement = Arrangement.spacedBy(12.dp),
        ) {
            OutlinedTextField(
                value = formState.data[PrimerInputElementType.CARD_NUMBER].orEmpty(),
                onValueChange = { controller.updateCardNumber(it) },
                label = { Text("Card number") },
                isError = hasFieldError(formState, PrimerInputElementType.CARD_NUMBER),
                supportingText = fieldErrorText(formState, PrimerInputElementType.CARD_NUMBER),
                keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
                singleLine = true,
                modifier = Modifier.fillMaxWidth(),
            )

            Row(
                modifier = Modifier.fillMaxWidth(),
                horizontalArrangement = Arrangement.spacedBy(12.dp),
            ) {
                OutlinedTextField(
                    value = formState.data[PrimerInputElementType.EXPIRY_DATE].orEmpty(),
                    onValueChange = { controller.updateExpiryDate(it) },
                    label = { Text("MM/YY") },
                    isError = hasFieldError(formState, PrimerInputElementType.EXPIRY_DATE),
                    supportingText = fieldErrorText(formState, PrimerInputElementType.EXPIRY_DATE),
                    keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
                    singleLine = true,
                    modifier = Modifier.weight(1f),
                )
                OutlinedTextField(
                    value = formState.data[PrimerInputElementType.CVV].orEmpty(),
                    onValueChange = { controller.updateCvv(it) },
                    label = { Text("CVV") },
                    isError = hasFieldError(formState, PrimerInputElementType.CVV),
                    supportingText = fieldErrorText(formState, PrimerInputElementType.CVV),
                    keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
                    singleLine = true,
                    modifier = Modifier.weight(1f),
                )
            }

            OutlinedTextField(
                value = formState.data[PrimerInputElementType.CARDHOLDER_NAME].orEmpty(),
                onValueChange = { controller.updateCardholderName(it) },
                label = { Text("Cardholder name") },
                isError = hasFieldError(formState, PrimerInputElementType.CARDHOLDER_NAME),
                supportingText = fieldErrorText(formState, PrimerInputElementType.CARDHOLDER_NAME),
                keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Words),
                singleLine = true,
                modifier = Modifier.fillMaxWidth(),
            )
        }
    }
    ```
  </Tab>

  <Tab title="iOS">
    Override the `cardDetails` slot and recompose the form from the `CardFormDefaults` per-field building blocks. Each block keeps the SDK's formatting, validation, and inline error display, and is self-hiding when its field is not part of the active configuration.

    ### Vertical layout

    ```swift theme={"dark"}
    PrimerCardForm(cardDetails: { session in
      VStack(spacing: 16) {
        CardFormDefaults.cardNumber(session)
        CardFormDefaults.expiryDate(session)
        CardFormDefaults.cvv(session)
        CardFormDefaults.cardholderName(session)

        // Co-badged selector: shown only when multiple networks are detected.
        CardFormDefaults.cardNetwork(session)
      }
      .padding()
    })
    ```

    ### Grouped layout

    Place expiry and CVV side by side:

    ```swift theme={"dark"}
    PrimerCardForm(cardDetails: { session in
      VStack(spacing: 16) {
        CardFormDefaults.cardNumber(session)

        HStack(spacing: 12) {
          CardFormDefaults.expiryDate(session)
          CardFormDefaults.cvv(session)
        }

        CardFormDefaults.cardholderName(session)
        CardFormDefaults.cardNetwork(session)
      }
    })
    ```

    <Note>
      When you recompose the individual card fields, add `CardFormDefaults.cardNetwork(session)` yourself so co-badged cards still get a selector — the built-in one is suppressed once you replace the default `cardDetails` section. The submit button stays at its default `CardFormDefaults.submitButton` unless you also override the `submitButton` slot.
    </Note>
  </Tab>
</Tabs>

## Step 3: Style the inputs

<Tabs>
  <Tab title="Web">
    Style card inputs using CSS variables. These properties are passed through to the secure iframes:

    ```css theme={"dark"}
    primer-card-form {
      /* Input styling */
      --primer-input-height: 48px;
      --primer-input-padding: 12px 16px;
      --primer-input-border-radius: 8px;

      /* Colors */
      --primer-color-background-input-default: #ffffff;
      --primer-color-border-input-default: #e0e0e0;
      --primer-color-border-input-focus: #2f98ff;
      --primer-color-border-input-error: #f44336;

      /* Typography */
      --primer-input-font-size: 16px;
      --primer-input-font-family: system-ui, sans-serif;
      --primer-color-text-input: #333333;
      --primer-color-text-placeholder: #999999;
    }
    ```

    <Info>
      For a complete list of CSS variables, see the [Styling guide](/docs/checkout/primer-checkout/build-your-ui/styling-customization).
    </Info>

    ### Adding custom labels

    Wrap inputs with labels for better accessibility:

    ```html theme={"dark"}
    <primer-card-form>
      <div slot="card-form-content" class="card-form-with-labels">
        <label class="input-group">
          <span class="label-text">Card Number</span>
          <primer-input-card-number></primer-input-card-number>
        </label>

        <div class="row">
          <label class="input-group">
            <span class="label-text">Expiry Date</span>
            <primer-input-card-expiry></primer-input-card-expiry>
          </label>

          <label class="input-group">
            <span class="label-text">CVV</span>
            <primer-input-cvv></primer-input-cvv>
          </label>
        </div>

        <label class="input-group">
          <span class="label-text">Cardholder Name</span>
          <primer-input-card-holder-name></primer-input-card-holder-name>
        </label>

        <primer-card-form-submit>Pay Now</primer-card-form-submit>
      </div>
    </primer-card-form>
    ```

    ```css theme={"dark"}
    .input-group {
      display: flex;
      flex-direction: column;
      gap: 4px;
    }

    .label-text {
      font-size: 14px;
      font-weight: 500;
      color: var(--primer-color-text-primary);
    }
    ```
  </Tab>

  <Tab title="Android">
    Handle validation errors by extracting error messages from `fieldErrors` to display under each field:

    ```kotlin theme={"dark"}
    private fun hasFieldError(
        state: PrimerCardFormController.State,
        type: PrimerInputElementType,
    ): Boolean {
        return state.fieldErrors?.any { it.inputElementType == type } == true
    }

    private fun fieldErrorText(
        state: PrimerCardFormController.State,
        type: PrimerInputElementType,
    ): (@Composable () -> Unit)? {
        val error = state.fieldErrors?.firstOrNull { it.inputElementType == type }
        return error?.let {
            { Text(it.errorId) }
        }
    }
    ```

    <Info>
      Validation errors appear after the user leaves a field (on blur), not while typing. The SDK handles this timing internally.
    </Info>
  </Tab>

  <Tab title="iOS">
    Field visual styling is theme-driven. Pass a `PrimerCheckoutTheme` to the session and the `CardFormDefaults` fields pick up its design tokens (colors, spacing, typography, and shape) automatically — there is no per-field styling struct.

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

    init(clientToken: String) {
      _session = StateObject(
        wrappedValue: PrimerCheckoutSession(
          clientToken: clientToken,
          theme: PrimerCheckoutTheme()
        )
      )
    }
    ```

    <Info>
      Validation errors appear automatically beneath each field. The SDK handles error timing -- errors show after the user leaves a field, not while typing. For the full list of design tokens, see the [PrimerCheckoutTheme reference](/docs/sdk/ios-checkout/v3.0.0-beta/api/theming/primer-theme).
    </Info>
  </Tab>
</Tabs>

## Step 4: Handle events and submission

<Tabs>
  <Tab title="Web">
    Listen for events to provide feedback and handle the payment flow:

    ```javascript theme={"dark"}
    const cardForm = document.querySelector('primer-card-form');

    // Handle validation errors
    cardForm.addEventListener('primer:card-error', (event) => {
      const { inputType, error } = event.detail;
      console.log(`Error in ${inputType}: ${error.message}`);

      // Show error feedback to user
      showFieldError(inputType, error.message);
    });

    // Handle successful validation
    cardForm.addEventListener('primer:card-success', (event) => {
      const { inputType } = event.detail;
      console.log(`${inputType} is valid`);

      // Clear any previous error
      clearFieldError(inputType);
    });
    ```

    <Info>
      For comprehensive event handling patterns, see the [Events guide](/docs/checkout/primer-checkout/configuration/events).
    </Info>

    ### Programmatic submission

    You can submit the card form programmatically instead of using the built-in submit button:

    ```javascript theme={"dark"}
    const cardForm = document.querySelector('primer-card-form');

    // Your custom submit button
    document.getElementById('my-submit-button').addEventListener('click', async () => {
      try {
        await cardForm.submit();
      } catch (error) {
        console.error('Submission failed:', error);
      }
    });
    ```

    ### Setting cardholder name programmatically

    If you collect the cardholder name elsewhere (e.g., from a shipping form), you can set it programmatically:

    ```javascript theme={"dark"}
    const cardForm = document.querySelector('primer-card-form');

    // Set cardholder name from your form
    const name = document.getElementById('shipping-name').value;
    cardForm.setCardholderName(name);
    ```

    <Info>
      When using `setCardholderName()`, you don't need to include the `<primer-input-card-holder-name>` component in your form.
    </Info>
  </Tab>

  <Tab title="Android">
    Call `controller.submit()` to tokenize the card data. Disable the button when the form is invalid or a submission is in progress:

    ```kotlin theme={"dark"}
    Button(
        onClick = { controller.submit() },
        enabled = formState.isFormValid && !formState.isLoading,
        modifier = Modifier.fillMaxWidth(),
    ) {
        if (formState.isLoading) {
            CircularProgressIndicator(
                modifier = Modifier.size(20.dp),
                color = Color.White,
                strokeWidth = 2.dp,
            )
        } else {
            Text("Pay")
        }
    }
    ```
  </Tab>

  <Tab title="iOS">
    Override the `submitButton` slot and call `session.submit()` to tokenize the card data. Read `session.state` to control the button and show loading:

    ```swift theme={"dark"}
    PrimerCardForm(submitButton: { session in
      Button(action: { session.submit() }) {
        if session.state.isLoading {
          ProgressView()
            .tint(.white)
        } else {
          Text("Pay now")
        }
      }
      .frame(maxWidth: .infinity, minHeight: 48)
      .buttonStyle(.borderedProminent)
      .disabled(!session.state.isValid || session.state.isLoading)
    })
    ```

    ### Setting cardholder name programmatically

    If you collect the name elsewhere, set it on the session without showing the field:

    ```swift theme={"dark"}
    session.updateCardholderName("Jane Doe")
    ```

    <Info>
      When billing or card fields are driven entirely from your own data, omit the matching `CardFormDefaults` building block and push the value through the session's `update*` methods instead.
    </Info>
  </Tab>
</Tabs>

## Step 5: Handle payment completion

<Tabs>
  <Tab title="Web">
    Listen for the checkout state to handle successful payments:

    ```javascript theme={"dark"}
    const checkout = document.querySelector('primer-checkout');

    checkout.addEventListener('primer:state-change', (event) => {
      const state = event.detail;

      if (state.isSuccessful) {
        // Payment completed successfully
        showSuccessMessage();
        redirectToConfirmation();
      }

      if (state.paymentFailure) {
        // Payment failed
        showErrorMessage(state.paymentFailure.message);
      }
    });
    ```
  </Tab>

  <Tab title="Android">
    Handle payment outcomes by observing `checkout.state`:

    ```kotlin theme={"dark"}
    val state by checkout.state.collectAsStateWithLifecycle()
    LaunchedEffect(state) {
        when (val s = state) {
            is PrimerCheckoutState.Success -> {
                Log.d("Checkout", "Payment ID: ${s.checkoutData.payment.id}")
            }
            is PrimerCheckoutState.Failure -> {
                Log.e("Checkout", "Diagnostics: ${s.error.diagnosticsId}")
            }
            else -> Unit
        }
    }

    PrimerCheckoutHost(checkout = checkout) {
        // Card form content
    }
    ```
  </Tab>

  <Tab title="iOS">
    Use the `onCompletion` closure on the `.primerCheckoutSession(_:onCompletion:)` modifier to handle the terminal `PrimerCheckoutState`:

    ```swift theme={"dark"}
    ScrollView {
      PrimerCardForm()
    }
    .primerCheckoutSession(session) { state in
      switch state {
      case let .success(result):
        print("Payment ID: \(result.paymentId)")
      case let .failure(error):
        print("Error: \(error.localizedDescription)")
      case .dismissed:
        break
      default:
        // .initializing and .ready are lifecycle states and are
        // never delivered to onCompletion.
        break
      }
    }
    ```
  </Tab>
</Tabs>

## Common mistakes to avoid

<Tabs>
  <Tab title="Web">
    <AccordionGroup>
      <Accordion title="Duplicate card forms">
        Don't include both `<primer-card-form>` and `<primer-payment-method type="PAYMENT_CARD">` in your layout. The payment method component already renders a card form internally.

        ```html theme={"dark"}
        <!-- WRONG: Duplicate card forms -->
        <div slot="payments">
          <primer-card-form>...</primer-card-form>
          <primer-payment-method type="PAYMENT_CARD"></primer-payment-method>
        </div>

        <!-- CORRECT: Use one or the other -->
        <div slot="payments">
          <primer-card-form>...</primer-card-form>
          <primer-payment-method type="PAYPAL"></primer-payment-method>
        </div>
        ```
      </Accordion>

      <Accordion title="Inputs outside the form context">
        Card input components must be descendants of `<primer-card-form>`. They won't work if placed outside.

        ```html theme={"dark"}
        <!-- WRONG -->
        <div>
          <primer-card-form></primer-card-form>
          <primer-input-card-number></primer-input-card-number>
        </div>

        <!-- CORRECT -->
        <primer-card-form>
          <div slot="card-form-content">
            <primer-input-card-number></primer-input-card-number>
          </div>
        </primer-card-form>
        ```
      </Accordion>

      <Accordion title="Dynamic rendering timing issues">
        When rendering card forms dynamically, ensure the parent `<primer-card-form>` exists before adding input children:

        ```javascript theme={"dark"}
        // WRONG: Adding inputs before the form
        container.innerHTML = `
          <primer-input-card-number></primer-input-card-number>
          <primer-card-form></primer-card-form>
        `;

        // CORRECT: Form first, then inputs
        container.innerHTML = `
          <primer-card-form>
            <div slot="card-form-content">
              <primer-input-card-number></primer-input-card-number>
            </div>
          </primer-card-form>
        `;
        ```
      </Accordion>

      <Accordion title="Missing slot attribute">
        When customizing the card form layout, always use the `card-form-content` slot:

        ```html theme={"dark"}
        <!-- WRONG: Content not in a slot -->
        <primer-card-form>
          <div>
            <primer-input-card-number></primer-input-card-number>
          </div>
        </primer-card-form>

        <!-- CORRECT: Content in the card-form-content slot -->
        <primer-card-form>
          <div slot="card-form-content">
            <primer-input-card-number></primer-input-card-number>
          </div>
        </primer-card-form>
        ```
      </Accordion>
    </AccordionGroup>
  </Tab>

  <Tab title="Android">
    <AccordionGroup>
      <Accordion title="Forgetting to observe state">
        Always collect the controller's state using `collectAsStateWithLifecycle()`. Without it, your UI won't update when the user types or when validation changes.

        ```kotlin theme={"dark"}
        // WRONG: State not observed
        val formState = controller.state.value

        // CORRECT: State observed reactively
        val formState by controller.state.collectAsStateWithLifecycle()
        ```
      </Accordion>

      <Accordion title="Creating the controller outside PrimerCheckoutHost">
        The card form controller must be created within the `PrimerCheckoutHost` or `PrimerCheckoutSheet` scope.

        ```kotlin theme={"dark"}
        // WRONG: Controller outside host
        val controller = rememberCardFormController(checkout)
        PrimerCheckoutHost(checkout = checkout) {
            CustomCardForm(controller)
        }

        // CORRECT: Controller inside host
        PrimerCheckoutHost(checkout = checkout) {
            val controller = rememberCardFormController(checkout)
            CustomCardForm(controller)
        }
        ```
      </Accordion>
    </AccordionGroup>
  </Tab>

  <Tab title="iOS">
    <AccordionGroup>
      <Accordion title="Using the session from the wrong slot">
        Use the `PrimerCardFormSession` passed into each slot closure. Capturing a session from elsewhere — or building fields with a session the form did not provide — won't work.

        ```swift theme={"dark"}
        // WRONG: Building a field from an unrelated session reference
        PrimerCardForm(cardDetails: { session in
          VStack {
            CardFormDefaults.cardNumber(otherSession) // Wrong session
          }
        })

        // CORRECT: Use the session parameter from this slot
        PrimerCardForm(cardDetails: { session in
          VStack {
            CardFormDefaults.cardNumber(session)
          }
        })
        ```
      </Accordion>

      <Accordion title="Not observing state in a custom subview">
        When you extract a slot into its own `View`, mark the captured session as `@ObservedObject`. Without it, your UI won't update when validation changes.

        ```swift theme={"dark"}
        // WRONG: Session not observed — button never re-evaluates
        struct SubmitButton: View {
          let session: PrimerCardFormSession
          var body: some View {
            Button("Pay") { session.submit() }
              .disabled(false) // Always enabled
          }
        }

        // CORRECT: Observe the session so state changes re-render
        struct SubmitButton: View {
          @ObservedObject var session: PrimerCardFormSession
          var body: some View {
            Button("Pay") { session.submit() }
              .disabled(!session.state.isValid || session.state.isLoading)
          }
        }
        ```
      </Accordion>
    </AccordionGroup>
  </Tab>
</Tabs>

## Complete example

<Tabs>
  <Tab title="Web">
    Here's a complete example combining all the concepts:

    ```html theme={"dark"}
    <primer-checkout client-token="your-client-token">
      <primer-main slot="main">
        <div slot="payments">
          <h2>Pay with Card</h2>

          <primer-card-form>
            <div slot="card-form-content" class="custom-card-form">
              <label class="input-group">
                <span class="label">Card Number</span>
                <primer-input-card-number></primer-input-card-number>
              </label>

              <div class="row">
                <label class="input-group">
                  <span class="label">Expiry</span>
                  <primer-input-card-expiry></primer-input-card-expiry>
                </label>
                <label class="input-group">
                  <span class="label">CVV</span>
                  <primer-input-cvv></primer-input-cvv>
                </label>
              </div>

              <label class="input-group">
                <span class="label">Name on Card</span>
                <primer-input-card-holder-name></primer-input-card-holder-name>
              </label>

              <primer-card-form-submit class="submit-button">
                Complete Payment
              </primer-card-form-submit>
            </div>
          </primer-card-form>

          <!-- Other payment methods -->
          <primer-payment-method type="PAYPAL"></primer-payment-method>
          <primer-payment-method type="APPLE_PAY"></primer-payment-method>
        </div>
      </primer-main>
    </primer-checkout>

    <style>
      .custom-card-form {
        display: flex;
        flex-direction: column;
        gap: 16px;
        padding: 20px;
        background: var(--primer-color-background-outlined-default);
        border-radius: 8px;
      }

      .input-group {
        display: flex;
        flex-direction: column;
        gap: 4px;
      }

      .label {
        font-size: 14px;
        font-weight: 500;
        color: var(--primer-color-text-primary);
      }

      .row {
        display: flex;
        gap: 16px;
      }

      .row > * {
        flex: 1;
      }

      .submit-button {
        margin-top: 8px;
      }

      @media (max-width: 480px) {
        .row {
          flex-direction: column;
        }
      }
    </style>

    <script>
      const cardForm = document.querySelector('primer-card-form');
      const checkout = document.querySelector('primer-checkout');

      // Handle field errors
      cardForm.addEventListener('primer:card-error', (event) => {
        const { inputType, error } = event.detail;
        const input = cardForm.querySelector(`primer-input-${inputType}`);
        input?.classList.add('has-error');
      });

      // Clear errors on success
      cardForm.addEventListener('primer:card-success', (event) => {
        const { inputType } = event.detail;
        const input = cardForm.querySelector(`primer-input-${inputType}`);
        input?.classList.remove('has-error');
      });

      // Handle payment completion
      checkout.addEventListener('primer:state-change', (event) => {
        if (event.detail.isSuccessful) {
          window.location.href = '/confirmation';
        }
      });
    </script>
    ```
  </Tab>

  <Tab title="Android">
    ```kotlin theme={"dark"}
    @Composable
    fun CustomCardFormScreen(clientToken: String) {
        val checkout = rememberPrimerCheckoutController(clientToken)
        val checkoutState by checkout.state.collectAsStateWithLifecycle()

        LaunchedEffect(checkoutState) {
            when (val s = checkoutState) {
                is PrimerCheckoutState.Success -> {
                    Log.d("Checkout", "Payment ID: ${s.checkoutData.payment.id}")
                }
                is PrimerCheckoutState.Failure -> {
                    Log.e("Checkout", "Diagnostics: ${s.error.diagnosticsId}")
                }
                else -> Unit
            }
        }

        when (checkoutState) {
            is PrimerCheckoutState.Loading -> {
                Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
                    CircularProgressIndicator()
                }
            }
            is PrimerCheckoutState.Ready -> {
                PrimerCheckoutHost(checkout = checkout) {
                    val controller = rememberCardFormController(checkout)
                    val formState by controller.state.collectAsStateWithLifecycle()

                    Column(
                        modifier = Modifier
                            .fillMaxWidth()
                            .padding(16.dp),
                        verticalArrangement = Arrangement.spacedBy(12.dp),
                    ) {
                        Text(
                            text = "Enter card details",
                            style = MaterialTheme.typography.titleMedium,
                        )

                        OutlinedTextField(
                            value = formState.data[PrimerInputElementType.CARD_NUMBER].orEmpty(),
                            onValueChange = { controller.updateCardNumber(it) },
                            label = { Text("Card number") },
                            keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
                            singleLine = true,
                            modifier = Modifier.fillMaxWidth(),
                        )

                        Row(
                            modifier = Modifier.fillMaxWidth(),
                            horizontalArrangement = Arrangement.spacedBy(12.dp),
                        ) {
                            OutlinedTextField(
                                value = formState.data[PrimerInputElementType.EXPIRY_DATE].orEmpty(),
                                onValueChange = { controller.updateExpiryDate(it) },
                                label = { Text("MM/YY") },
                                keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
                                singleLine = true,
                                modifier = Modifier.weight(1f),
                            )
                            OutlinedTextField(
                                value = formState.data[PrimerInputElementType.CVV].orEmpty(),
                                onValueChange = { controller.updateCvv(it) },
                                label = { Text("CVV") },
                                keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
                                singleLine = true,
                                modifier = Modifier.weight(1f),
                            )
                        }

                        OutlinedTextField(
                            value = formState.data[PrimerInputElementType.CARDHOLDER_NAME].orEmpty(),
                            onValueChange = { controller.updateCardholderName(it) },
                            label = { Text("Cardholder name") },
                            keyboardOptions = KeyboardOptions(
                                capitalization = KeyboardCapitalization.Words,
                            ),
                            singleLine = true,
                            modifier = Modifier.fillMaxWidth(),
                        )

                        Button(
                            onClick = { controller.submit() },
                            enabled = formState.isFormValid && !formState.isLoading,
                            modifier = Modifier.fillMaxWidth(),
                        ) {
                            if (formState.isLoading) {
                                CircularProgressIndicator(
                                    modifier = Modifier.size(20.dp),
                                    color = Color.White,
                                    strokeWidth = 2.dp,
                                )
                            } else {
                                Text("Pay")
                            }
                        }
                    }
                }
            }
        }
    }
    ```
  </Tab>

  <Tab title="iOS">
    ```swift theme={"dark"}
    import SwiftUI
    import PrimerSDK

    struct CustomCardFormScreen: View {
      @StateObject private var session: PrimerCheckoutSession

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

      var body: some View {
        ScrollView {
          PrimerCardForm(
            cardDetails: { formSession in
              VStack(spacing: 20) {
                Text("Payment details")
                  .font(.title2)
                  .fontWeight(.semibold)
                  .frame(maxWidth: .infinity, alignment: .leading)

                CardFormDefaults.cardNumber(formSession)

                HStack(spacing: 12) {
                  CardFormDefaults.expiryDate(formSession)
                  CardFormDefaults.cvv(formSession)
                }

                CardFormDefaults.cardholderName(formSession)

                // Co-badged selector: shown only when multiple networks are detected.
                CardFormDefaults.cardNetwork(formSession)
              }
            },
            submitButton: { formSession in
              Button(action: { formSession.submit() }) {
                if formSession.state.isLoading {
                  ProgressView()
                    .tint(.white)
                } else {
                  Text("Pay now")
                }
              }
              .frame(maxWidth: .infinity, minHeight: 48)
              .background(formSession.state.isValid ? Color.blue : Color.gray)
              .foregroundColor(.white)
              .cornerRadius(10)
              .disabled(!formSession.state.isValid || formSession.state.isLoading)
            }
          )
          .padding()
        }
        .primerCheckoutSession(session) { state in
          switch state {
          case let .success(result):
            print("Payment ID: \(result.paymentId)")
          case let .failure(error):
            print("Error: \(error.localizedDescription)")
          case .dismissed:
            break
          default:
            break
          }
        }
      }
    }
    ```
  </Tab>
</Tabs>

## See also

<CardGroup cols={2}>
  <Card title="Styling guide" icon="palette" href="/docs/checkout/primer-checkout/build-your-ui/styling-customization">
    Customize colors, fonts, and spacing
  </Card>

  <Card title="Events guide" icon="bolt" href="/docs/checkout/primer-checkout/configuration/events">
    Handle checkout events across platforms
  </Card>

  <Card title="Error handling" icon="triangle-exclamation" href="/docs/checkout/primer-checkout/build-your-ui/error-handling">
    Display and handle payment errors
  </Card>

  <Card title="Android SDK Reference" icon="android" href="/docs/sdk/android-checkout/v3.0.0-beta/api/overview">
    Android SDK API documentation
  </Card>

  <Card title="iOS SDK Reference" icon="apple" href="/docs/sdk/ios-checkout/v3.0.0-beta/api/card-form/primer-card-form">
    iOS SDK API documentation
  </Card>
</CardGroup>
