← Forms

Live Card Preview

A payment card above the form fills in as you type. Each digit drops into place over its placeholder, the card changes colour and label once the number shows what kind of card it is, and a soft frame glides to whichever part you’re filling in. Reach the security code and the card flips over to show its back.

Try made-up numbers: nothing you type leaves this page.

How to use it

  1. Paste the snippet just before </head>.
  2. Wrap your card fields in an element with data-live-card-preview: the card is added at the top.
  3. Give the fields their standard autocomplete values (cc-number, cc-name, cc-exp, cc-csc) so the script can find them, and so browsers can autofill them.

Options

Add any of these attributes to the same element.

data-name-placeholderShown on the card before a name is typed. Default YOUR NAME.
data-autoplayIt types a made-up card by itself while in view, flips it for the code, clears it and types another. It stops as soon as you use the fields.

The fields are formatted as you type: the number in groups (4-6-5 for American Express) and the expiry as MM / YY. The card itself is decorative and hidden from screen readers; tapping part of it jumps to that field. Listen for the brand event: e.detail.brand is visa, mastercard, amex, discover or empty.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Live Card Preview: a payment card that fills in as you type. Each digit
    // drops into place over its placeholder, the card's colour and label
    // change when the number shows which kind of card it is, a soft frame
    // glides to whichever part of the card you're filling in, and the card
    // flips over to show the back when you reach the security code.
    // Usage: <div data-live-card-preview>
    //   <input autocomplete="cc-number"> <input autocomplete="cc-name">
    //   <input autocomplete="cc-exp"> <input autocomplete="cc-csc"></div>
    // The card is added at the top. The fields are found by their
    // autocomplete values and formatted as you type (number in groups,
    // expiry as MM/YY). With no fields inside, it's a card on its own.
    // Optional: data-name-placeholder (default "YOUR NAME"), data-autoplay
    // (it types a made-up card by itself while in view, then clears it and
    // types another; it stops as soon as you use the fields).
    // Listen for the "brand" event: e.detail.brand is visa, mastercard,
    // amex, discover or "" when the number changes kind.
    var DEFAULTS = { name: 'YOUR NAME' };
    var BRANDS = {
      '': { label: '', bg: 'linear-gradient(135deg,#3b1a5c 0%,#6a0dad 55%,#a24bd8 100%)' },
      visa: { label: 'Visa', bg: 'linear-gradient(135deg,#0f2a6b 0%,#1f4fbf 60%,#3a7bf0 100%)' },
      mastercard: { label: 'Mastercard', bg: 'linear-gradient(135deg,#2b1b17 0%,#7a2c12 55%,#e0791f 100%)' },
      amex: { label: 'Amex', bg: 'linear-gradient(135deg,#0d4a4a 0%,#13817c 60%,#3dbfa8 100%)' },
      discover: { label: 'Discover', bg: 'linear-gradient(135deg,#3a2a0d 0%,#a35c0c 60%,#f29a2e 100%)' }
    };
    var DEMOS = [
      { number: '4000123456789010', name: 'JANE DOE', exp: '0829', cvc: '123' },
      { number: '5425233430109903', name: 'SAM OKAFOR', exp: '1127', cvc: '481' },
      { number: '371449635398431', name: 'ALEX MORGAN', exp: '0330', cvc: '7724' }
    ];

    var css =
      '.lcp{display:grid;gap:1.2em;justify-items:center}' +
      '.lcp-card{width:20em;max-width:100%;aspect-ratio:85.6/54;perspective:1100px;cursor:default;-webkit-tap-highlight-color:transparent;user-select:none;-webkit-user-select:none}' +
      '.lcp-inner{position:relative;width:100%;height:100%;transform-style:preserve-3d}' +
      '.lcp-front,.lcp-back{position:absolute;inset:0;overflow:hidden;border-radius:1em;color:#fff;-webkit-backface-visibility:hidden;backface-visibility:hidden;' +
      'box-shadow:0 14px 30px rgba(20,10,40,.28),0 2px 6px rgba(20,10,40,.18);text-align:left}' +
      '.lcp-back{transform:rotateY(180deg)}' +
      '.lcp-bg{position:absolute;inset:0}' +
      '.lcp-front::after,.lcp-back::after{content:"";position:absolute;inset:0;pointer-events:none;' +
      'background:radial-gradient(120% 90% at 100% 0,rgba(255,255,255,.22),transparent 55%),radial-gradient(80% 70% at 0 100%,rgba(0,0,0,.25),transparent 60%)}' +
      '.lcp-chip{position:absolute;left:1.5em;top:1.5em;width:2.5em;height:1.9em;border-radius:.35em;background:linear-gradient(135deg,#f6dd8e,#c9a34a 60%,#efd383);' +
      'box-shadow:inset 0 0 0 1px rgba(90,60,0,.35)}' +
      '.lcp-chip::before{content:"";position:absolute;left:30%;right:30%;top:0;bottom:0;border-left:1px solid rgba(90,60,0,.35);border-right:1px solid rgba(90,60,0,.35)}' +
      '.lcp-chip::after{content:"";position:absolute;left:0;right:0;top:50%;border-top:1px solid rgba(90,60,0,.35)}' +
      '.lcp-brand{position:absolute;right:1.4em;top:1.35em;height:1.6em;line-height:1.6em;font-size:1em;font-weight:800;font-style:italic;letter-spacing:.02em;white-space:nowrap}' +
      '.lcp-num{position:absolute;left:1.4em;top:5.1em;padding:0 .1em;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:1.3em;' +
      'line-height:1.5em;white-space:pre;letter-spacing:.04em;text-shadow:0 1px 2px rgba(0,0,0,.3)}' +
      '.lcp-ch{display:inline-block;min-width:.6em;text-align:center}' +
      '.lcp-ph{opacity:.55}' +
      '.lcp-row{position:absolute;left:1.5em;right:1.5em;bottom:1.25em;display:flex;justify-content:space-between;align-items:flex-end;gap:1em}' +
      '.lcp-field{display:block;min-width:0;padding:0 .15em}' +
      '.lcp-field small{display:block;font-size:.55em;letter-spacing:.08em;text-transform:uppercase;opacity:.72}' +
      '.lcp-field span{display:block;font-size:.9em;font-weight:600;letter-spacing:.06em;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;text-transform:uppercase}' +
      '.lcp-holder{flex:1;max-width:12em}' +
      '.lcp-exp{flex:none;text-align:right;font-variant-numeric:tabular-nums}' +
      '.lcp-focus{position:absolute;left:0;top:0;width:0;height:0;border:1.5px solid rgba(255,255,255,.75);border-radius:.45em;background:rgba(255,255,255,.08);' +
      'box-sizing:border-box;opacity:0;pointer-events:none}' +
      '.lcp-stripe{position:absolute;left:0;right:0;top:1.4em;height:2.5em;background:#111}' +
      '.lcp-sig{position:absolute;left:1.5em;right:1.5em;top:5.2em;height:2.1em;display:flex;align-items:center;justify-content:flex-end;border-radius:.25em;' +
      'background:repeating-linear-gradient(-45deg,#f4f4f5 0 .35em,#e4e4e7 .35em .7em)}' +
      '.lcp-cvc{min-width:3.4em;height:1.6em;margin-right:.25em;padding:0 .4em;box-sizing:border-box;line-height:1.6em;border-radius:.2em;background:#fff;color:#18181b;' +
      'font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-style:italic;font-weight:700;text-align:center;letter-spacing:.1em}' +
      '.lcp-cvc-label{position:absolute;right:2.6em;top:7.1em;font-size:.6em;letter-spacing:.08em;text-transform:uppercase;opacity:.75}' +
      '.lcp-back .lcp-brand{top:auto;bottom:1.1em}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
    function on(v) { return v !== undefined && v !== 'false'; }
    function brandOf(n) {
      if (/^4/.test(n)) return 'visa';
      if (/^(5[1-5]|2[2-7])/.test(n)) return 'mastercard';
      if (/^3[47]/.test(n)) return 'amex';
      if (/^6(011|5)/.test(n)) return 'discover';
      return '';
    }
    function groups(brand) { return brand === 'amex' ? [4, 6, 5] : [4, 4, 4, 4]; }
    function formatNumber(digits, brand) {
      var g = groups(brand), out = [], i = 0;
      g.forEach(function (n) { if (i < digits.length) out.push(digits.slice(i, i + n)); i += n; });
      return out.join(' ');
    }

    function setup(el) {
      var d = el.dataset;
      var mock = !!el.closest('[aria-hidden="true"]');
      var namePh = d.namePlaceholder || DEFAULTS.name;
      var inputs = {
        number: el.querySelector('[autocomplete="cc-number"]'),
        name: el.querySelector('[autocomplete="cc-name"]'),
        exp: el.querySelector('[autocomplete="cc-exp"]'),
        cvc: el.querySelector('[autocomplete="cc-csc"]')
      };
      el.classList.add('lcp');
      var card = document.createElement('div');
      card.className = 'lcp-card';
      card.setAttribute('aria-hidden', 'true');
      card.innerHTML =
        '<div class="lcp-inner">' +
        '<div class="lcp-front"><div class="lcp-bg"></div><span class="lcp-chip"></span><span class="lcp-brand"></span><div class="lcp-num"></div>' +
        '<div class="lcp-row"><span class="lcp-field lcp-holder"><small>Card holder</small><span></span></span>' +
        '<span class="lcp-field lcp-exp"><small>Expires</small><span></span></span></div><span class="lcp-focus"></span></div>' +
        '<div class="lcp-back"><div class="lcp-bg"></div><span class="lcp-stripe"></span><span class="lcp-cvc-label">CVC</span>' +
        '<div class="lcp-sig"><span class="lcp-cvc"></span></div><span class="lcp-brand"></span></div></div>';
      el.insertBefore(card, el.firstChild);
      var inner = card.querySelector('.lcp-inner'), front = card.querySelector('.lcp-front'), backFace = card.querySelector('.lcp-back');
      var numBox = card.querySelector('.lcp-num'), holder = card.querySelector('.lcp-holder'), expBox = card.querySelector('.lcp-exp');
      var holderText = holder.querySelector('span'), expText = expBox.querySelector('span'), cvcText = card.querySelector('.lcp-cvc');
      var brandEls = card.querySelectorAll('.lcp-brand'), focusBox = card.querySelector('.lcp-focus');

      var state = { number: '', name: '', exp: '', cvc: '' }, brand = null, slots = [], flipped = false, focused = '';

      function setBrand(b, quiet) {
        if (b === brand) return;
        var first = brand === null;
        brand = b;
        [front, backFace].forEach(function (face) {
          var all = face.querySelectorAll('.lcp-bg'), old = all[all.length - 1], bg = document.createElement('div');
          bg.className = 'lcp-bg';
          bg.style.background = BRANDS[b].bg;
          old.parentNode.insertBefore(bg, old.nextSibling);
          if (first || reduced()) { old.remove(); return; }
          anime.animate(bg, { opacity: [0, 1], duration: 500, ease: 'out(2)', onComplete: function () { old.remove(); } });
        });
        Array.prototype.forEach.call(brandEls, function (be) {
          be.textContent = BRANDS[b].label;
          if (!first && !reduced() && b) anime.animate(be, { opacity: [0, 1], scale: [0.6, 1], duration: 420, ease: 'outBack(1.6)' });
        });
        buildSlots();
        if (!quiet && !mock) el.dispatchEvent(new CustomEvent('brand', { bubbles: true, detail: { brand: b } }));
      }
      function buildSlots() {
        numBox.textContent = '';
        slots = [];
        groups(brand).forEach(function (n, gi) {
          if (gi) numBox.appendChild(document.createTextNode(' '));
          for (var i = 0; i < n; i++) {
            var s = document.createElement('span');
            s.className = 'lcp-ch';
            numBox.appendChild(s);
            slots.push({ el: s, ch: null });
          }
        });
        drawNumber(true);
      }
      function drawNumber(still) {
        slots.forEach(function (s, i) {
          var c = state.number.charAt(i) || '#';
          if (c === s.ch) return;
          var was = s.ch;
          s.ch = c;
          s.el.textContent = c;
          s.el.classList.toggle('lcp-ph', c === '#');
          if (still || was === null || reduced()) return;
          anime.utils.remove(s.el);
          if (c === '#') anime.animate(s.el, { y: ['50%', '0%'], opacity: [0, 1], duration: 220, ease: 'out(2)' });
          else anime.animate(s.el, { y: ['-70%', '0%'], opacity: [0, 1], scale: [1.3, 1], duration: 300, ease: 'out(3)' });
        });
      }
      function drawText(target, text, ph) {
        var shown = text || ph;
        if (target.textContent === shown) return;
        var grew = text.length > (target._lcpLen || 0);
        target._lcpLen = text.length;
        target.textContent = shown;
        target.style.opacity = text ? '' : '.55';
        if (!reduced() && text && grew) anime.animate(target, { x: ['.15em', 0], duration: 200, ease: 'out(2)' });
      }
      function draw() {
        setBrand(brandOf(state.number));
        drawNumber(false);
        drawText(holderText, state.name.toUpperCase(), namePh);
        var e = state.exp;
        drawText(expText, e ? (e.slice(0, 2) + (e.length > 2 ? '/' + e.slice(2) : '')) : '', 'MM/YY');
        drawText(cvcText, state.cvc, brand === 'amex' ? '••••' : '•••');
      }

      function region(key) { return key === 'number' ? numBox : key === 'name' ? holder : key === 'exp' ? expBox : null; }
      function flip(to) {
        if (to === flipped) return;
        flipped = to;
        if (reduced()) { anime.utils.set(inner, { rotateY: to ? 180 : 0 }); return; }
        anime.utils.remove(inner);
        anime.animate(inner, { rotateY: to ? 180 : 0, duration: 760, ease: 'inOut(3)' });
      }
      function focusOn(key) {
        focused = key;
        flip(key === 'cvc');
        var r = region(key);
        if (!r) { anime.animate(focusBox, { opacity: 0, duration: 200, ease: 'out(2)' }); return; }
        var pad = parseFloat(getComputedStyle(card).fontSize) * 0.3;
        var box = { x: r.offsetLeft - pad, y: r.offsetTop - pad, width: r.offsetWidth + pad * 2, height: r.offsetHeight + pad * 2 };
        if (r === holder || r === expBox) { box.x += r.parentNode.offsetLeft; box.y += r.parentNode.offsetTop; }
        var visible = parseFloat(getComputedStyle(focusBox).opacity) > 0.05;
        if (reduced() || !visible) {
          anime.utils.set(focusBox, box);
          anime.animate(focusBox, { opacity: 1, duration: reduced() ? 0 : 250, ease: 'out(2)' });
        } else {
          anime.animate(focusBox, { x: box.x, y: box.y, width: box.width, height: box.height, opacity: 1, duration: 420, ease: 'inOut(3)' });
        }
      }

      setBrand('', true);
      draw();

      // Real fields: format them as you type and mirror them onto the card.
      function caretDigits(input) {
        var pos = input.selectionStart == null ? input.value.length : input.selectionStart;
        return input.value.slice(0, pos).replace(/\D/g, '').length;
      }
      function placeCaret(input, digits) {
        var v = input.value, i = 0, seen = 0;
        while (i < v.length && seen < digits) { if (/\d/.test(v.charAt(i))) seen++; i++; }
        try { input.setSelectionRange(i, i); } catch (err) { /* not focused */ }
      }
      function read(key, typed) {
        var input = inputs[key];
        if (!input) return;
        var v = input.value, cd = typed ? caretDigits(input) : 0;
        if (key === 'number') {
          var digits = v.replace(/\D/g, '');
          digits = digits.slice(0, brandOf(digits) === 'amex' ? 15 : 16);
          state.number = digits;
          input.value = formatNumber(digits, brandOf(digits));
        } else if (key === 'exp') {
          var e = v.replace(/\D/g, '');
          if (e.length === 1 && e > '1') e = '0' + e;
          state.exp = e.slice(0, 4);
          input.value = state.exp.length > 2 ? state.exp.slice(0, 2) + ' / ' + state.exp.slice(2) : state.exp;
        } else if (key === 'cvc') {
          state.cvc = v.replace(/\D/g, '').slice(0, brandOf(state.number) === 'amex' ? 4 : 3);
          input.value = state.cvc;
        } else {
          state.name = v.slice(0, 26);
        }
        if (typed && key !== 'name' && document.activeElement === input) placeCaret(input, cd);
        draw();
      }
      var blurTimer = null;
      Object.keys(inputs).forEach(function (key) {
        var input = inputs[key];
        if (!input) return;
        if (key !== 'name') input.setAttribute('inputmode', 'numeric');
        if (key === 'exp' && !input.placeholder) input.placeholder = 'MM / YY';
        input.addEventListener('input', function (e) { if (e.isTrusted) stop(); read(key, true); });
        input.addEventListener('focus', function () { clearTimeout(blurTimer); focusOn(key); });
        input.addEventListener('blur', function () {
          blurTimer = setTimeout(function () { if (!el.contains(document.activeElement)) focusOn(''); }, 60);
        });
        input.addEventListener('pointerdown', stop);
        input.addEventListener('keydown', function (e) { if (e.isTrusted) stop(); });
        if (input.value) read(key, false);
      });
      if (!mock && inputs.number) {
        // Tapping a part of the card jumps to its field.
        card.addEventListener('click', function (e) {
          stop();
          var t = e.target;
          var key = flipped ? 'cvc' : numBox.contains(t) ? 'number' : holder.contains(t) ? 'name' : expBox.contains(t) ? 'exp' : 'number';
          if (inputs[key]) inputs[key].focus();
        });
      }

      // Autoplay: types a made-up card, flips for the code, then clears it.
      var stopped = false, timer = null, round = 0;
      function stop() { if (!stopped) { stopped = true; clearTimeout(timer); } }
      function later(fn, ms) { timer = setTimeout(function () { if (!stopped) fn(); }, ms); }
      function put(key, value) {
        if (inputs[key]) { inputs[key].value = value; read(key, false); }
        else { state[key] = value; draw(); }
      }
      function typeInto(key, text, i, then) {
        put(key, text.slice(0, i));
        if (i >= text.length) { later(then, 450); return; }
        later(function () { typeInto(key, text, i + 1, then); }, key === 'name' ? 75 : 95);
      }
      function sequence(keys, demo, k, then) {
        if (k >= keys.length) { then(); return; }
        focusOn(keys[k]);
        later(function () { typeInto(keys[k], demo[keys[k]], 1, function () { sequence(keys, demo, k + 1, then); }); }, keys[k] === 'cvc' ? 800 : 380);
      }
      function clearAll() {
        focusOn('');
        ['cvc', 'exp', 'name', 'number'].forEach(function (k) { put(k, ''); });
      }
      function run() {
        var demo = DEMOS[round++ % DEMOS.length];
        sequence(['number', 'name', 'exp', 'cvc'], demo, 0, function () {
          later(function () {
            focusOn('');
            later(function () { clearAll(); later(run, 1300); }, 1700);
          }, 900);
        });
      }
      if (on(d.autoplay) && !reduced()) {
        new IntersectionObserver(function (entries) {
          if (stopped) return;
          clearTimeout(timer);
          if (entries[0].isIntersecting) { clearAll(); later(run, 700); }
        }).observe(el);
      }
    }

    function init() {
      document.querySelectorAll('[data-live-card-preview]').forEach(function (el) {
        if (el._lcp) return;
        el._lcp = true;
        setup(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-live-card-preview>
  <input autocomplete="cc-number" name="number" placeholder="Card number">
  <input autocomplete="cc-name" name="name" placeholder="Name on card">
  <input autocomplete="cc-exp" name="expiry" placeholder="MM / YY">
  <input autocomplete="cc-csc" name="cvc" placeholder="Security code">
</div>