> ## 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](/checkout/primer-checkout/installation)
    * [Created your first payment](/checkout/primer-checkout/first-payment)
    * Familiarity with [layout customization](/checkout/primer-checkout/build-your-ui/layout-customization)
  </Tab>

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

  <Tab title="iOS">
    * A working checkout integration ([First Payment](/checkout/primer-checkout/first-payment))
    * Familiarity with [scope-based customization](/checkout/primer-checkout/build-your-ui/layout-customization)
    * Understanding of `PrimerCardFormScope` 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-cardholder-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">
    `PrimerCardFormScope` gives you full control over the card form. You access it through `PrimerCheckout`'s scope closure and use SDK-managed fields (`PrimerCardNumberField`, `PrimerExpiryDateField`, `PrimerCvvField`, `PrimerCardholderNameField`) in your own SwiftUI layout. Primer handles validation, formatting, and PCI compliance.

    ### Key state properties

    | Property      | Type                 | Description                                 |
    | ------------- | -------------------- | ------------------------------------------- |
    | `isValid`     | `Bool`               | Whether all required fields pass validation |
    | `isLoading`   | `Bool`               | Whether a submission is in progress         |
    | `fieldErrors` | `[PrimerFieldError]` | Validation errors per field                 |
    | `data`        | `PrimerCardFormData` | Current field values                        |
  </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-cardholder-name></primer-input-cardholder-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()

        when (checkoutState) {
            is PrimerCheckoutState.Loading -> CircularProgressIndicator()
            is PrimerCheckoutState.Ready -> {
                PrimerCheckoutHost(
                    checkout = checkout,
                    onEvent = { event ->
                        when (event) {
                            is PrimerCheckoutEvent.Success -> { /* navigate */ }
                            is PrimerCheckoutEvent.Failure -> { /* log error */ }
                        }
                    },
                ) {
                    val cardFormController = rememberCardFormController(checkout)
                    CustomCardForm(cardFormController)
                }
            }
        }
    }
    ```
  </Tab>

  <Tab title="iOS">
    Access the card form scope from `PrimerCheckout` and replace the `screen` property with your own SwiftUI layout:

    ```swift theme={"dark"}
    PrimerCheckout(
      clientToken: clientToken,
      scope: { checkoutScope in
        if let cardScope: PrimerCardFormScope = checkoutScope.getPaymentMethodScope(PrimerCardFormScope.self) {
          cardScope.screen = { scope in
            AnyView(CustomCardForm(scope: scope))
          }
        }
      }
    )
    ```

    <Warning>
      **Scope hierarchy matters**

      SDK-managed fields must be created from the `PrimerCardFormScope` instance provided in the `screen` closure. Using a scope from outside this closure will not work.
    </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-cardholder-name></primer-input-cardholder-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-cardholder-name></primer-input-cardholder-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">
    Arrange SDK-managed fields in your own SwiftUI layout. The scope provides field components that you position freely:

    ### Vertical layout

    ```swift theme={"dark"}
    struct CustomCardForm: View {
      let scope: any PrimerCardFormScope
      @State private var isValid = false
      @State private var isLoading = false

      var body: some View {
        VStack(spacing: 16) {
          scope.PrimerCardNumberField(label: "Card number", styling: nil)
          scope.PrimerExpiryDateField(label: "Expiry date", styling: nil)
          scope.PrimerCvvField(label: "CVV", styling: nil)
          scope.PrimerCardholderNameField(label: "Name on card", styling: nil)

          Button(action: { scope.submit() }) {
            Text("Pay now")
              .frame(maxWidth: .infinity, minHeight: 48)
          }
          .buttonStyle(.borderedProminent)
          .disabled(!isValid || isLoading)
        }
        .padding()
        .task {
          for await state in scope.state {
            isValid = state.isValid
            isLoading = state.isLoading
          }
        }
      }
    }
    ```

    ### Grouped layout

    Place expiry and CVV side by side:

    ```swift theme={"dark"}
    VStack(spacing: 16) {
      scope.PrimerCardNumberField(label: "Card number", styling: nil)

      HStack(spacing: 12) {
        scope.PrimerExpiryDateField(label: "Expiry date", styling: nil)
        scope.PrimerCvvField(label: "CVV", styling: nil)
      }

      scope.PrimerCardholderNameField(label: "Name on card", styling: nil)
    }
    ```
  </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](/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-cardholder-name></primer-input-cardholder-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">
    Apply `PrimerFieldStyling` to individual fields for visual customization:

    ```swift theme={"dark"}
    let fieldStyling = PrimerFieldStyling(
      fontSize: 16,
      backgroundColor: Color(.secondarySystemBackground),
      borderColor: Color(.separator),
      focusedBorderColor: .blue,
      cornerRadius: 10,
      fieldHeight: 48
    )

    scope.PrimerCardNumberField(label: "Card number", styling: fieldStyling)
    scope.PrimerExpiryDateField(label: "Expiry date", styling: fieldStyling)
    scope.PrimerCvvField(label: "CVV", styling: fieldStyling)
    ```

    <Info>
      Validation errors appear automatically beneath each field. The SDK handles error timing -- errors show after the user leaves a field, not while typing.
    </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](/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-cardholder-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">
    Call `scope.submit()` to tokenize the card data. Monitor the form state to control the button and show loading:

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

    ### Setting cardholder name programmatically

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

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

    <Info>
      When using `updateCardholderName()`, you don't need to include `PrimerCardholderNameField` in your form.
    </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 through the `onEvent` callback:

    ```kotlin theme={"dark"}
    PrimerCheckoutHost(
        checkout = checkout,
        onEvent = { event ->
            when (event) {
                is PrimerCheckoutEvent.Success -> {
                    Log.d("Checkout", "Payment ID: ${event.checkoutData.payment.id}")
                }
                is PrimerCheckoutEvent.Failure -> {
                    Log.e("Checkout", "Diagnostics: ${event.error.diagnosticsId}")
                }
            }
        },
    ) {
        // Card form content
    }
    ```
  </Tab>

  <Tab title="iOS">
    Use `onCompletion` on `PrimerCheckout` to handle the payment result:

    ```swift theme={"dark"}
    PrimerCheckout(
      clientToken: clientToken,
      scope: { checkoutScope in
        if let cardScope: PrimerCardFormScope = checkoutScope.getPaymentMethodScope(PrimerCardFormScope.self) {
          cardScope.screen = { scope in
            AnyView(CustomCardForm(scope: scope))
          }
        }
      },
      onCompletion: { state in
        switch state {
        case .success(let result):
          print("Payment ID: \(result.payment?.id ?? "")")
        case .failure(let error):
          print("Error: \(error.errorId)")
        default:
          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, onEvent = {}) {
            CustomCardForm(controller)
        }

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

  <Tab title="iOS">
    <AccordionGroup>
      <Accordion title="Using scope outside the screen closure">
        SDK-managed fields must be created from the scope provided in the `screen` closure. Using a different scope reference won't work.

        ```swift theme={"dark"}
        // WRONG: Using cardScope directly
        cardScope.screen = { scope in
          AnyView(
            VStack {
              cardScope.PrimerCardNumberField(label: "Card", styling: nil) // Wrong scope
            }
          )
        }

        // CORRECT: Using the scope parameter
        cardScope.screen = { scope in
          AnyView(
            VStack {
              scope.PrimerCardNumberField(label: "Card", styling: nil)
            }
          )
        }
        ```
      </Accordion>

      <Accordion title="Not observing state">
        Always use `.task` with `for await` to observe the form state. Without it, your UI won't update when validation changes.

        ```swift theme={"dark"}
        // WRONG: State not observed
        Button("Pay") { scope.submit() }
          .disabled(false) // Always enabled

        // CORRECT: State observed via async stream
        Button("Pay") { scope.submit() }
          .disabled(!isValid || isLoading)
          .task {
            for await state in scope.state {
              isValid = state.isValid
              isLoading = 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-cardholder-name></primer-input-cardholder-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()

        when (checkoutState) {
            is PrimerCheckoutState.Loading -> {
                Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
                    CircularProgressIndicator()
                }
            }
            is PrimerCheckoutState.Ready -> {
                PrimerCheckoutHost(
                    checkout = checkout,
                    onEvent = { event ->
                        when (event) {
                            is PrimerCheckoutEvent.Success -> {
                                Log.d("Checkout", "Payment ID: ${event.checkoutData.payment.id}")
                            }
                            is PrimerCheckoutEvent.Failure -> {
                                Log.e("Checkout", "Diagnostics: ${event.error.diagnosticsId}")
                            }
                        }
                    },
                ) {
                    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"}
    struct CustomCardForm: View {
      let scope: any PrimerCardFormScope
      @State private var isValid = false
      @State private var isLoading = false

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

          scope.PrimerCardNumberField(label: "Card number", styling: nil)

          HStack(spacing: 12) {
            scope.PrimerExpiryDateField(label: "Expiry date", styling: nil)
            scope.PrimerCvvField(label: "CVV", styling: nil)
          }

          scope.PrimerCardholderNameField(label: "Name on card", styling: nil)

          Button(action: { scope.submit() }) {
            if isLoading {
              ProgressView()
                .tint(.white)
            } else {
              Text("Pay now")
            }
          }
          .frame(maxWidth: .infinity, minHeight: 48)
          .background(isValid ? Color.blue : Color.gray)
          .foregroundColor(.white)
          .cornerRadius(10)
          .disabled(!isValid || isLoading)
        }
        .padding()
        .task {
          for await state in scope.state {
            isValid = state.isValid
            isLoading = state.isLoading
          }
        }
      }
    }

    // Usage
    PrimerCheckout(
      clientToken: clientToken,
      scope: { checkoutScope in
        if let cardScope: PrimerCardFormScope = checkoutScope.getPaymentMethodScope(PrimerCardFormScope.self) {
          cardScope.screen = { scope in
            AnyView(CustomCardForm(scope: scope))
          }
        }
      },
      onCompletion: { state in
        switch state {
        case .success(let result):
          print("Payment ID: \(result.payment?.id ?? "")")
        case .failure(let error):
          print("Error: \(error.errorId)")
        default:
          break
        }
      }
    )
    ```
  </Tab>
</Tabs>

## See also

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

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

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

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