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

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:

> [tab] Web
>
> * [Installed Primer Checkout](https://primer.io/docs/checkout/primer-checkout/installation.md)
> * [Created your first payment](https://primer.io/docs/checkout/primer-checkout/first-payment.md)
> * Familiarity with [layout customization](https://primer.io/docs/checkout/primer-checkout/build-your-ui/layout-customization.md)

> [tab] Android
>
> * A working checkout integration ([First Payment](https://primer.io/docs/checkout/primer-checkout/first-payment.md))
> * Familiarity with the [controller pattern](https://primer.io/docs/checkout/primer-checkout/core-concepts.md)
> * Understanding of [PrimerCheckoutHost](https://primer.io/docs/checkout/primer-checkout/build-your-ui/layout-customization.md) for inline integration

> [tab] iOS
>
> * A working checkout integration ([First Payment](https://primer.io/docs/checkout/primer-checkout/first-payment.md))
> * Familiarity with [layout customization](https://primer.io/docs/checkout/primer-checkout/build-your-ui/layout-customization.md)
> * Understanding of `PrimerCardForm` and its `@ViewBuilder` slots for card form control

## Understanding the card form architecture

> [tab] 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:
>
> slot: name
>
> Exposes a customizable slot
>
> → name
>
> Default content, replaced when you customize the slot
>
> > [!NOTE]
> >
> > **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
>
> ### 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] 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] 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`. The card number is masked to its first six and last four digits, for example `424242••••••4242`. A number of 10 digits or fewer keeps only the last four. The CVV is always empty |

## Step 1: Create the card form

> [tab] 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>
> > ```

> [tab] 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] iOS
>
> Hold a `PrimerCheckoutSession` as a `@StateObject` and place a `PrimerCardForm` inside a view that has the `.primerCheckoutSession(_:theme: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 .success, .failure and .dismissed
>     }
>   }
> }
> ```
>
> > [!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(_:theme:onCompletion:)` modifier applied, or the form renders an empty placeholder.

## Step 2: Customize the layout

> [tab] 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] 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] 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)
>   }
>   .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)
>   }
> })
> ```
>
> > [!NOTE]
> >
> > `CardFormDefaults.cardNumber(session)` keeps the co-badged network selector inside the card number field, so a recomposed layout handles co-badged cards without extra work. Do not add `CardFormDefaults.cardNetwork(session)` next to it, or two selectors appear. The submit button stays at its default `CardFormDefaults.submitButton` unless you also override the `submitButton` slot.

## Step 3: Style the inputs

> [tab] 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;
> }
> ```
>
> > [!NOTE]
> >
> > For a complete list of CSS variables, see the [Styling guide](https://primer.io/docs/checkout/primer-checkout/build-your-ui/styling-customization.md).
>
> ### 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] 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) }
>     }
> }
> ```
>
> > [!NOTE]
> >
> > Validation errors appear after the user leaves a field (on blur), not while typing. The SDK handles this timing internally.

> [tab] 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()
>     )
>   )
> }
> ```
>
> > [!NOTE]
> >
> > 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](https://primer.io/docs/sdk/ios-checkout/v3.0.0-beta/api/theming/primer-theme.md).

## Step 4: Handle events and submission

> [tab] 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);
> });
> ```
>
> > [!NOTE]
> >
> > For comprehensive event handling patterns, see the [Events guide](https://primer.io/docs/checkout/primer-checkout/configuration/events.md).
>
> ### 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);
> ```
>
> > [!NOTE]
> >
> > When using `setCardholderName()`, you don't need to include the `<primer-input-card-holder-name>` component in your form.

> [tab] 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] 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)
> })
> ```
>
> While a payment runs (`session.state.isLoading` is `true`), the SDK disables the card and billing address fields you compose from `CardFormDefaults`. It ignores further `submit()` calls, so a double tap starts only one payment. It ignores `session.cancel()` until the payment ends, because a running payment cannot be stopped.
>
> ### Pre-filling cardholder name
>
> If you already know the name, pre-fill it from the session. Keep `CardFormDefaults.cardholderName(session)` in your layout. The form becomes valid only after the SDK's cardholder name field validates the value. A name set without the field leaves `session.state.isValid` false.
>
> ```swift theme={"dark"}
> session.updateCardholderName("Jane Doe")
> ```
>
> > [!NOTE]
> >
> > When billing fields come entirely from your own data, replace the `billingAddress` slot. Return `EmptyView()`, or compose only the `CardFormDefaults` billing building blocks you want. Then push the values through the session's `update*` methods. Keep the card field building blocks (`cardNumber`, `expiryDate`, `cvv`, `cardholderName`) in your layout: `session.state.isValid` only becomes `true` after each of them has validated its value. The billing address is sent with the payment only when a postal code is set.

## Step 5: Handle payment completion

> [tab] 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] 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.primerError.diagnosticsId}")
>         }
>         else -> Unit
>     }
> }
>
> PrimerCheckoutHost(checkout = checkout) {
>     // Card form content
> }
> ```

> [tab] iOS
>
> Use the `onCompletion` closure on the `.primerCheckoutSession(_:theme:onCompletion:)` modifier to handle the checkout outcome. Once the checkout is ready, `.failure` arrives once per failed attempt and the form stays usable so the shopper can retry. `.success` or `.dismissed` ends the session and arrives once. A `.failure` while the session is still initializing also ends it:
>
> ```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:
>     break
>   }
> }
> ```

## Common mistakes to avoid

> [tab] Web
>
> > [accordion] 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] 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] 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] 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>
> > ```

> [tab] Android
>
> > [accordion] 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] 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)
> > }
> > ```

> [tab] iOS
>
> > [accordion] Using the session from the wrong slot
> >
> > Use the `PrimerCardFormSession` passed into each slot closure. It is the session for the form you are rendering, and the form keeps passing you the current one when the checkout restarts. `checkoutSession.cardForm` is `nil` until the checkout is ready. The SDK replaces its value when the checkout restarts. Fields built from a session you kept elsewhere stay bound to the old session, not to the form on screen.
> >
> > ```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] 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)
> >   }
> > }
> > ```

## Complete example

> [tab] 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] 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.primerError.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] 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)
>           }
>         },
>         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
>       }
>     }
>   }
> }
> ```

## See also

> [card] [Styling guide](https://primer.io/docs/checkout/primer-checkout/build-your-ui/styling-customization.md)
>
> Customize colors, fonts, and spacing

> [card] [Events guide](https://primer.io/docs/checkout/primer-checkout/configuration/events.md)
>
> Handle checkout events across platforms

> [card] [Error handling](https://primer.io/docs/checkout/primer-checkout/build-your-ui/error-handling.md)
>
> Display and handle payment errors

> [card] [Android SDK Reference](https://primer.io/docs/sdk/android-checkout/v3.0.0-beta/api/overview.md)
>
> Android SDK API documentation

> [card] [iOS SDK Reference](https://primer.io/docs/sdk/ios-checkout/v3.0.0-beta/api/card-form/primer-card-form.md)
>
> iOS SDK API documentation


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.