As developers, we so often need to build forms that take credit card information, constantly repeating that code on each website, modifying it to fit into a PHP site here, a React site there, and into the strange custom-built content management system that can’t go down for a second.
Well, there’s a way to wrap all the separate credit card separate fields into one single field, add its own error correction, and use it seamlessly across every site in your portfolio, no modifications needed. We can build it using web components.
Web components are essentially custom HTML elements that work across all the different platforms you must support. In this article, we’ll learn how to work with web components to build a credit card form.
Note that we’re only looking at frontend concerns, so be sure to have encryption and other security measures in place to properly care for the data a user entrusts you.
First, let’s put together what a credit card form looks like before we make it a web component.
We’ll need four main fields to start with: the name on the card, the card number, the expiration date, and the CVV code. We could expand on this by adding a billing address and other relevant information, but we’ll stick to these four fields for the purposes of this article:
<form> <label> <span class="nmoncd">Name on Card</span> <input name="nameoncard" type="text" placeholder="Lucious B. Byinshtuff"> </label> <label> <span class="cdnum">Card Number <span>(Dashes not required)</span></span> <input name="cardnumber" type="text" placeholder="DONT-USEA-REAL-NMBR"> </label> <label class="exp"> <span class="expdt">Exp. Date</span> <input name="experationdate" type="text" placeholder="MM/YYYY"> </label> <label class="cvc"> <span class="cvcnum">CVC</span> <input name="cvcnumber" type="text" placeholder="XXX"> </label> <button type="submit"> Buy Now </button> </form>
See the Pen
Credit Card Form – Vanilla by Undead Institute (@undeadinstitute)
on CodePen.
Alright, let’s now use a web component to place our four credit card fields into a single custom HTML element.
We’ll call the element <credit-card>
(autonomous web component names, that is, the ones we name ourselves, must have a hyphen in them).
There are three major parts to any web component: HTML, CSS, and JavaScript. The HTML is the element name that we just decided and, if we have any standard HTML we want to include (which we do), we must add that within a <template>
element.
We can also add <slot>
elements for any content we might want to change whenever we create a new instance of the web component. However, for our particular purposes, we don’t need any slots since users will put in their own information and we won’t need to differentiate anything between instances.
Thus, the template includes the four fields from above with a wrapper div
to ensure the layout remains consistent across instances. Then the template element wraps around all of that:
<template id="cc-template"> <div class="wrapper"> <label> <span class="nmoncd">Name on Card</span> <input name="nameoncard" type="text" placeholder="Lucious B. Byinshtuff"> </label> <label> <span class="cdnum">Card Number <span>(Dashes not required)</span></span> <input name="cardnumber" type="text" placeholder="DONT-USEA-REAL-NMBR"> </label> <label class="exp"> <span class="expdt">Exp. Date</span> <input name="experationdate" type="text" placeholder="MM/YYYY"> </label> <label class="cvc"> <span class="cvcnum">CVC</span> <input name="cvcnumber" type="text" placeholder="XXX"> </label> </div> </template>
The next step is to set up our new custom element in JavaScript. We’ll extend the HTMLElement
class and give our new element all the properties of a built-in element. We can then add on and expand our custom element’s capabilities.
Let’s first grab the template we set up and insert it into the element called the shadow DOM, which is an internal DOM to the element. We’ll do this in the constructor (the function that runs when we’re creating the element and sets everything up):
customElements.define( "credit-card", //the name of the element we chose above class extends HTMLElement { constructor() { super(); let cctemplate = document.getElementById("cc-template"); //grabs the template element const shadowRoot = this.attachShadow({ mode: "open" }).appendChild( cctemplate.content.cloneNode(true) //clones the template's HTML and attaches it to our new custom element ); } } );
See the Pen
Credit Card Form – Web Component by Undead Institute (@undeadinstitute)
on CodePen.
Because shadowDom
is tangentially associated with the full DOM, it acts as a black box that won’t let CSS styles invade its premises. But, that also means it will not let those styles out.
In this way, your CSS can be encapsulated within the element and kept with your web component on any site or context you put it in.
How do we set these styles, though? We use a style element in our template. Just put your CSS in a style element in your template, which affects the content of the web component, but nothing else:
<template id="cc-template"> <style> * { box-sizing: border-box; } input { padding: 0.6em 0.25em 0; font-size: 1.5em; height: 2.1em; margin-bottom: 0.25em; border: none; border-bottom: 1px solid peachpuff; font-family: Raleway, Helvetica, sans-serif; font-weight: 100; border-radius: 0.25em; } label span { font-size: 0.75em; } label { position: relative; } label>span { position: absolute; top: 0.25em; left: 0.25em; } .cdnum span { color: #555; font-size: 0.8em; } .cvc { width: 25%; } label, input { width: 100%; } .exp { width: calc(75% - 0.75em); } ::placeholder { color: #ccc; opacity: 1; } ::-ms-input-placeholder { color: #ccc; } input:focus { outline-color: #eb0062; } .wrapper { width: 100%; display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center; background-color: #fff0f0; } </style> <!-- **The HTML code we already had in the template** --> </template>
Now that we have the web component in place, all we must do to use it is place the following in our code and our form fields appear:
<credit-card></credit-card>
By using web components to make our fields into an element, they’re still not hooked into the form in any way. If we submit the form as is, it won’t submit any content for our <credit-card>
subfields. To hook into the form’s built-in processes, we must make it a form-associated web component.
Everything we’ve done up to this point is supported in all browsers, but, unfortunately, form-associated web components aren’t available in Safari yet, and have limited availability in Firefox. Luckily they are fully supported in Edge and Chrome. (There’s also a polyfill available if you need it.)
To make a web component form-associated, set a formAssociated
variable to true
and attach an ElementInternals
object to the web component to give us the utilities necessary to implement form participation. We can do this via the attachInternals()
function:
customElements.define( "credit-card", class extends HTMLElement { static formAssociated = true; //shows our web component to be form-associated constructor() { super(); try { this.guts_ = this.attachInternals(); //attaches the ElementInternals object to the web component } catch {} //kept in a try/catch block so Safari doesn't choke let cctemplate = document.getElementById("cc-template"); const shadowRoot = this.attachShadow({ mode: "open" }).appendChild( cctemplate.content.cloneNode(true) ); } });
Now that <credit-card>
is form-associated and we have ElementInternals
attached, we need a way to send the values from <credit-card>
’s four subfields with the form once the form is submitted.
To do this, we must first select each input from the shadowRoot
using the querySelector
function and each input’s name attribute to select the elements with CSS. Once we have each input, we can create an event listener that updates what will be sent when the form is submitted.
We’ll create an update function that updates the values of each subfield so when the form is submitted, the latest info will be sent; we’ll call it updateValue()
.
The only problem is that since we’ll be within the element in the event listener, we must come back up to the custom element to run the update function. We can do this using the getRootNode()
function and the host property:
constructor() { /**The rest of the constructor code removed for simplicity**/ this.shadowRoot .querySelector('[name="nameoncard"]') .addEventListener("input", function (evt) { this.getRootNode().host.updateValue(); //call <credit-card>'s updateValue() function }); this.shadowRoot.querySelector('[name="cardnumber"]') .addEventListener("input", function (evt) { this.getRootNode().host.updateValue(); //call <credit-card>'s updateValue() function }); this.shadowRoot.querySelector( '[name="experationdate"]' ).addEventListener("input", function (evt) { this.getRootNode().host.updateValue(); //call <credit-card>'s updateValue() function }); this.cvvnumberEl_ = this.shadowRoot.querySelector( '[name="cvvnumber"]' ).addEventListener("input", function (evt) { this.getRootNode().host.updateValue(); //call <credit-card>'s updateValue() function }); this.updateValue(); //Run updateValue initially so that the fields will be sent in name/value pairs even if empty. } updateValue() { const ccinfo = new FormData(); //Since we're sending more than one field we need to use a FormData object. //Grab the current value of all fields this.nameoncard_ = this.shadowRoot.querySelector( '[name="nameoncard"]' ).value; this.cardnumber_ = this.shadowRoot.querySelector( '[name="cardnumber"]' ).value; this.experationdate_ = this.shadowRoot.querySelector( '[name="experationdate"]' ).value; this.cvvnumber_ = this.shadowRoot.querySelector( '[name="cvvnumber"]' ).value; //Append the value of each field to the FormData object as a name/value pair. The first parameter is the name, the second is the value ccinfo.append("nameoncard", this.nameoncard_); ccinfo.append("cardnumber", this.cardnumber_); ccinfo.append("experationdate", this.experationdate_); ccinfo.append("cvvnumber", this.cvvnumber_); this.guts_.setFormValue(ccinfo); //Using one of the utilities we got from ElementInternals, setFormValue(), we'll set the <credit-card> field's value using the FormData object. } } );
See the Pen
Credit Card Form – Form Associated by Undead Institute (@undeadinstitute)
on CodePen.
One other thing we can add to our web component is some content validation. We must ensure that a user can pay us easily, so correcting some common mistakes on the frontend can save us from card declines and similar issues over easily fixed errors.
We’ll use the setValidity
function that’s part of our ElementInternals
, which allows us to flag content that isn’t quite right and show an error message on input.
If the content passes our validation tests, we give it the all-clear with the following:
this.guts_.setValidity({});
If it doesn’t pass our test, we set the error type to true
, give it an error message, and, since we have multiple fields, hand it the field to put the error on:
this.guts_.setValidity( { typeMismatch: true }, //error type "Credit Card Number Invalid", //error message to show this.shadowRoot.querySelector('[name="cardnumber"]') //element on which to show error );
While you might think that there are at least a few simple rules we could use for verifying names, like including at least one space or only using alpha characters, names don’t really conform to a standard, and unless you have a constraint further down in the process, I’d recommend leaving error correction off (except maybe for little Bobby tables).
Credit card numbers aren’t just random numbers, nor can all sixteen-digit numbers be credit card numbers (American Express uses 15 digits, but we’ll ignore them for now).
For credit cards, the first six digits represent the issuer (such as Mastercard, Visa, and Discover); the next nine are their customer’s account number; and the last number is a checksum that ensures the previous fifteen numbers are accurate, that is, no numbers are transposed and no random number was substituted.
This checksum uses the Luhn algorithm. There are a few transpositions and substitutes that pass the Luhn algorithm, but it catches the vast majority of simple errors:
if ( checkLuhn(this.cardnumber_) == this.cardnumber_[this.cardnumber_.length - 1] ) { //Checks if the last digit is equal to the results of the Luhn algorithm this.guts_.setValidity({}); } else { this.guts_.setValidity( { typeMismatch: true }, "Credit Card Number Invalid", this.shadowRoot.querySelector('[name="cardnumber"]') ); }
The checkLuhn
function also removes any non-digit characters, like hyphens, before it runs the algorithm, so we don’t need to worry about whether the user puts them in the field or not.
Expiration dates must be a valid future date, which allows us to provide some error correction here.
For simplicity, I’m using date.parse
, but because that function is heavily implementation-dependent, meaning its interpretation of the date could depend on the browser and/or operating system, I’d recommend using a date library to ensure that you get consistent answers across systems and browsers:
let expdate = this.experationdate_.split("/"), //separate the inputted month and year today = new Date(Date.now()); //grab today's date if (Date.parse(expdate[1] + "-" + expdate[0])) { //check if the inputted date is parsable as YYYY-MM (a format Date.parse() understands) if (today.getFullYear() < parseInt(expdate[1])) { //if the year is in the future, it's valid this.guts_.setValidity({}); } else if ( today.getFullYear() == parseInt(expdate[1]) && today.getMonth() + 1 <= parseInt(expdate[0]) //if it's the current year and the month is current or in the future, it's valid ) { this.guts_.setValidity({}); } else { //otherwise the month and year are in the past and the card is expired this.guts_.setValidity( { rangeUnderflow: true }, "Card is Expired", this.shadowRoot.querySelector('[name="experationdate"]') ); } } else { //the date wasn't parsable so we throw an error this.guts_.setValidity( { typeMismatch: true }, "Invalid Expiration Date", this.shadowRoot.querySelector('[name="experationdate"]') ); }
Lastly, the CVV code must be at least three digits (American Express uses four, but we’ll continue to ignore this edge case).
We can test this with JavaScript, but HTML also has built-in ways to handle testing using the maxlength
attribute and the pattern
attribute. Remember, we must still hook into setValidity
for the form to check the input against the pattern:
For testing with HTML, use the following:
<input maxlength="3" pattern="[0-9][0-9][0-9]" name="cvvnumber" type="text" placeholder="XXX">
For JavaScript, use this code:
let pattern = this.shadowRoot .querySelector('[name="cvvnumber"]') .getAttribute("pattern"); //grab the contents of the pattern attribute let rgx = new RegExp(pattern); //create a Regular Expression object if (rgx.test(this.cvvnumber_)) { //Test whether the inputted value satisfies the pattern, if so it's valid this.guts_.setValidity({}); } else { this.guts_.setValidity( { patternMismatch: true }, "CVV code should be three numbers", this.shadowRoot.querySelector('[name="cvvnumber"]') ); }
Now, each of the above validation scripts works perfectly in isolation, but anytime we set this.guts_.setValidity({});
, it erases all flags.
If the CVV code validation is the last one in your list and it’s valid, but the expiration date or the credit card number isn’t, then those errors will skip and the form will go through.
We could reset error flags individually, but since we have multiple typeMismatch
errors, a fixed expiration date would wipe out an error on the credit card number.
Thus, we’ll save the errors in an array and process them all at once at the end. If we don’t collect any errors, the content is valid and can send it through the following:
let errors = []; //create an array to hold errors this.cardnumber_ = this.shadowRoot.querySelector( '[name="cardnumber"]' ).value; if ( checkLuhn(this.cardnumber_) != this.cardnumber_[this.cardnumber_.length - 1] ) { //Add the error to the array errors.push([ { typeMismatch: true }, "Credit Card Number Invalid", this.shadowRoot.querySelector('[name="cardnumber"]') ]); } this.experationdate_ = this.shadowRoot.querySelector( '[name="experationdate"]' ).value; let expdate = this.experationdate_.split("/"), today = new Date(Date.now()); if (Date.parse(expdate[1] + "-" + expdate[0])) { if (today.getFullYear() < parseInt(expdate[1])) { //do nothing, field is valid } else if ( today.getFullYear() == parseInt(expdate[1]) && today.getMonth() + 1 <= parseInt(expdate[0]) ) { //do nothing, field is valid } else { errors.push([ { rangeUnderflow: true }, "Card is Expired", this.shadowRoot.querySelector('[name="experationdate"]') ]); } } else { errors.push([ { typeMismatch: true }, "Invalid Expiration Date", this.shadowRoot.querySelector('[name="experationdate"]') ]); } this.cvvnumber_ = this.shadowRoot.querySelector( '[name="cvvnumber"]' ).value; let pattern = this.shadowRoot .querySelector('[name="cvvnumber"]') .getAttribute("pattern"); let rgx = new RegExp(pattern); if (!this.cvvnumber_ || !rgx.test(this.cvvnumber_)) { errors.push([ { patternMismatch: true }, "CVV code should be Three Numbers", this.shadowRoot.querySelector('[name="cvvnumber"]') ]); } //If we have any errors, process them. If not, the input is valid and okay to go through. if (errors.length > 0) { for (let i = 0; i < errors.length; i++) { this.guts_.setValidity(errors[i][0], errors[i][1], errors[i][2]); } } else { this.guts_.setValidity({}); }
See the Pen
Credit Card Form – Validation by Undead Institute (@undeadinstitute)
on CodePen.
Well, there you have it, you built a full web component you can use as-is or take and make variations to. In fact, there’s a whole lot more validation you can do than what’s covered here. Happy web componenterizer-izing!
Install LogRocket via npm or script tag. LogRocket.init()
must be called client-side, not
server-side
$ npm i --save logrocket // Code: import LogRocket from 'logrocket'; LogRocket.init('app/id');
// Add to your HTML: <script src="https://cdn.lr-ingest.com/LogRocket.min.js"></script> <script>window.LogRocket && window.LogRocket.init('app/id');</script>
Would you be interested in joining LogRocket's developer community?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowBuild scalable admin dashboards with Filament and Laravel using Form Builder, Notifications, and Actions for clean, interactive panels.
Break down the parts of a URL and explore APIs for working with them in JavaScript, parsing them, building query strings, checking their validity, etc.
In this guide, explore lazy loading and error loading as two techniques for fetching data in React apps.
Deno is a popular JavaScript runtime, and it recently launched version 2.0 with several new features, bug fixes, and improvements […]