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:
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:
primer-checkout slot: main
└── primer-main → main
└── primer-payment-method type="PAYMENT_CARD"
└── primer-card-form slot: card-form-content
├── primer-input-card-number → card-form-content
├── primer-input-card-expiry → card-form-content
├── primer-input-cvv → card-form-content
├── primer-input-card-holder-name → card-form-content
├── primer-billing-address → card-form-content
├── primer-card-form-submit → card-form-content
└── primer-error-message → card-form-content form errors
slot: name Exposes a customizable slot
→ name Default content, replaced when you customize the slot
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-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
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 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. <!-- 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 ;
}
}
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 ;
}
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 );
}
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
Inputs outside the form context
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 >
Dynamic rendering timing issues
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 >
See also