John Rhea John is a storyteller with design and development skills. Prepare for the apocalypse with his zombie-themed web development books at undead.institute.

Credit card form web component tutorial

10 min read 2907

Credit Card Form Web Component Tutorial

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.

Creating a credit card form

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.

Creating a web component for the credit card fields

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

We made a custom demo for .
No really. Click here to check it out.

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>

Creating a custom JavaScript element

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>

Form-associated web components

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.

Web component content validation

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.

Conclusion

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!

: Full visibility into your web and mobile apps

LogRocket is a frontend application monitoring solution that lets you replay problems as if they happened in your own browser. Instead of guessing why errors happen, or asking users for screenshots and log dumps, LogRocket lets you replay the session to quickly understand what went wrong. It works perfectly with any app, regardless of framework, and has plugins to log additional context from Redux, Vuex, and @ngrx/store.

In addition to logging Redux actions and state, LogRocket records console logs, JavaScript errors, stacktraces, network requests/responses with headers + bodies, browser metadata, and custom logs. It also instruments the DOM to record the HTML and CSS on the page, recreating pixel-perfect videos of even the most complex single-page and mobile apps.

.
John Rhea John is a storyteller with design and development skills. Prepare for the apocalypse with his zombie-themed web development books at undead.institute.

Leave a Reply