Skip to main content
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:

Understanding the card form architecture

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:
Security by designThe 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

ComponentPurpose
primer-card-formContainer that provides context for all card inputs
primer-input-card-numberSecure hosted input for card number
primer-input-card-expirySecure hosted input for expiry date
primer-input-cvvSecure hosted input for CVV
primer-input-card-holder-nameInput for cardholder name (optional)
primer-card-form-submitSubmit button with built-in loading states

Step 1: Create the card form

Start by creating a custom card form using the card-form-content slot:
<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>
Component hierarchy mattersAll card input components must be nested inside <primer-card-form>. Placing them outside breaks the context connection and the form won’t work.
<!-- 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>

Step 2: Customize the layout

Vertical layout (default)

Stack inputs vertically for a clean, mobile-friendly form:
<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>
.card-form-vertical {
  display: flex;
  flex-direction: column;
  gap: var(--primer-space-small);
}

Grouped layout

Place expiry and CVV side by side:
<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>
.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:
.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;
  }
}

Step 3: Style the inputs

Style card inputs using CSS variables. These properties are passed through to the secure iframes:
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;
}
For a complete list of CSS variables, see the Styling guide.

Adding custom labels

Wrap inputs with labels for better accessibility:
<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>
.input-group {
  display: flex;
  flex-direction: column;
  gap: 4px;
}

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

Step 4: Handle events and submission

Listen for events to provide feedback and handle the payment flow:
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);
});
For comprehensive event handling patterns, see the Events guide.

Programmatic submission

You can submit the card form programmatically instead of using the built-in submit button:
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:
const cardForm = document.querySelector('primer-card-form');

// Set cardholder name from your form
const name = document.getElementById('shipping-name').value;
cardForm.setCardholderName(name);
When using setCardholderName(), you don’t need to include the <primer-input-card-holder-name> component in your form.

Step 5: Handle payment completion

Listen for the checkout state to handle successful payments:
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);
  }
});

Common mistakes to avoid

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.
<!-- 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>
Card input components must be descendants of <primer-card-form>. They won’t work if placed outside.
<!-- 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>
When rendering card forms dynamically, ensure the parent <primer-card-form> exists before adding input children:
// 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>
`;
When customizing the card form layout, always use the card-form-content slot:
<!-- 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>

Complete example

Here’s a complete example combining all the concepts:
<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>

See also

Styling guide

Customize colors, fonts, and spacing

Events guide

Handle checkout events across platforms

Error handling

Display and handle payment errors

Android SDK Reference

Android SDK API documentation

iOS SDK Reference

iOS SDK API documentation