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:
Cprimer-checkout
└── Mprimer-main
└── primer-payment-methodtype="PAYMENT_CARD"
└── Fprimer-card-formrenders automatically
├── #primer-input-card-number
├── #primer-input-card-expiry
├── #primer-input-cvv
├── #primer-input-cardholder-name
└── primer-card-form-submit
CRoot
MLayout
PContainer
FCard form
#Input fields
Actions / errors
Payment method
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-cardholder-nameInput for cardholder name (optional)
primer-card-form-submitSubmit button with built-in loading states

Step 1: Create a basic custom 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-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>
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-cardholder-name></primer-input-cardholder-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-cardholder-name></primer-input-cardholder-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-cardholder-name></primer-input-cardholder-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 card form events

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-cardholder-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-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>

Next steps