← E-commerce

Stamp Card

A loyalty card fills up as stamps thump down onto it one by one. Each stamp drops from above, squashes on impact with a ring of ink and jolts the card. Fill the last slot and the reward lights up.

Coffee Club

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-stamp-card to an element with the card’s title as its text, and the number of slots in data-total.
  3. Set data-start to the stamps the customer already has and data-count to where it should get to, or call animacious.stampCard(element) to add one.

Options

Add any of these attributes to the same element.

data-totalNumber of slots, the last one being the reward. Default 8.
data-startStamps already on the card. Default 0.
data-countHow many it stamps up to. Default all of them.
data-iconstar, cup or heart. Default star.
data-rewardText in the reward slot. Default “Free”.
data-colorInk colour. Default #6a0dad.
data-speedTime between stamps. Default 650 ms.
data-loopClear the card and stamp it again every few seconds while in view.

The count is announced to screen readers, and each stamp fires a stamp event with e.detail.count.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Stamp Card: a loyalty card fills up as stamps thump down onto it one by
    // one. Each stamp drops from above, hits with a squash, a ring of ink and
    // a little jolt of the card; fill the last slot and the reward lights up.
    // The element's text is the card's title.
    // Usage: <div data-stamp-card data-total="8">Coffee Club</div>
    // Optional: data-total (slots, default 8; the last one is the reward),
    // data-count (how many get stamped, default all), data-start (how many
    // are already stamped, default 0), data-icon (cup, star or heart; default
    // star), data-reward (text in the last slot, default "Free"),
    // data-color (ink colour, default #6a0dad), data-speed (ms between
    // stamps, default 650), data-loop (clear and stamp again every few
    // seconds while in view).
    // Fires a "stamp" event with e.detail.count for each stamp.
    // Script use: animacious.stampCard(el) adds one more stamp.
    var DEFAULTS = { total: 8, icon: 'star', reward: 'Free', color: '#6a0dad', speed: 650, hold: 2400 };

    var css =
      '.skc{display:block;width:22em;max-width:100%;padding:1em 1.1em 1.1em;box-sizing:border-box;border-radius:1em;color:#1a1a2e;text-align:left;' +
      'background:radial-gradient(circle at 100% 0,var(--skc-soft),transparent 55%),#fff;box-shadow:0 .5em 1.4em rgba(15,15,30,.1)}' +
      '.skc-head{display:flex;justify-content:space-between;align-items:baseline;gap:.8em;margin-bottom:.8em}' +
      '.skc-title{font-weight:800;letter-spacing:-.01em;font-size:1.05em;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}' +
      '.skc-count{font-size:.8em;font-weight:600;color:#6b6b80;white-space:nowrap}' +
      '.skc-grid{display:grid;grid-template-columns:repeat(var(--skc-cols),1fr);gap:.55em}' +
      '.skc-slot{position:relative;aspect-ratio:1;border-radius:50%;border:2px dashed #d6d0e2;display:grid;place-items:center}' +
      '.skc-slot.skc-reward{border-style:solid;border-color:var(--skc-c);color:var(--skc-c);font-size:.7em;font-weight:800;text-transform:uppercase;letter-spacing:.04em}' +
      '.skc-slot.skc-reward.skc-won{background:var(--skc-c);color:#fff;box-shadow:0 0 0 .3em var(--skc-soft),0 0 1.4em var(--skc-c)}' +
      '.skc-stamp{position:absolute;inset:-2px;display:grid;place-items:center;border-radius:50%;opacity:0;color:var(--skc-c);' +
      'border:.16em solid currentColor;background:radial-gradient(circle,var(--skc-soft) 0 55%,transparent 75%);mix-blend-mode:multiply}' +
      '.skc-stamp svg{width:58%;height:58%}' +
      '.skc-reward .skc-stamp svg{display:none}.skc-won .skc-stamp{color:#fff;background:none;mix-blend-mode:normal}' +
      '.skc-ring{position:absolute;inset:-2px;border-radius:50%;border:2px solid var(--skc-c);opacity:0;pointer-events:none}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    var ICONS = {
      star: '<path d="M12 2.8l2.8 5.8 6.3.9-4.6 4.4 1.1 6.3L12 17.2l-5.6 3 1.1-6.3-4.6-4.4 6.3-.9z" fill="currentColor"/>',
      cup: '<path d="M4 8h12v6a5 5 0 0 1-5 5H9a5 5 0 0 1-5-5z" fill="currentColor"/><path d="M16 10h1.5a2.5 2.5 0 0 1 0 5H16" fill="none" stroke="currentColor" stroke-width="2"/>' +
        '<path d="M8 2.5c-1 1.2 1 2 0 3.5M12 2.5c-1 1.2 1 2 0 3.5" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/>',
      heart: '<path d="M12 20.5S3.5 15 3.5 9.2A4.6 4.6 0 0 1 12 6.6a4.6 4.6 0 0 1 8.5 2.6C20.5 15 12 20.5 12 20.5z" fill="currentColor"/>'
    };

    function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
    function on(v) { return v !== undefined && v !== 'false'; }
    function soft(hex) {
      var m = /^#?([0-9a-f]{6})$/i.exec(hex);
      if (!m) return 'rgba(106,13,173,.12)';
      var n = parseInt(m[1], 16);
      return 'rgba(' + (n >> 16) + ',' + ((n >> 8) & 255) + ',' + (n & 255) + ',.13)';
    }

    function setup(el) {
      var d = el.dataset;
      var total = Math.max(2, +d.total || DEFAULTS.total);
      var color = d.color || DEFAULTS.color;
      var title = el.textContent.trim() || 'Loyalty card';
      el.classList.add('skc');
      el.style.setProperty('--skc-c', color);
      el.style.setProperty('--skc-soft', soft(color));
      el.style.setProperty('--skc-cols', total <= 5 ? total : Math.ceil(total / 2));
      var icon = '<svg viewBox="0 0 24 24" aria-hidden="true">' + (ICONS[d.icon] || ICONS[DEFAULTS.icon]) + '</svg>';
      var slots = '';
      for (var i = 0; i < total; i++) {
        var last = i === total - 1;
        slots += '<span class="skc-slot' + (last ? ' skc-reward' : '') + '">' + (last ? '<b></b>' : '') +
          '<span class="skc-stamp">' + icon + '</span><span class="skc-ring"></span></span>';
      }
      el.innerHTML = '<div class="skc-head"><span class="skc-title"></span><span class="skc-count" aria-live="polite"></span></div>' +
        '<div class="skc-grid" aria-hidden="true">' + slots + '</div>';
      el.querySelector('.skc-title').textContent = title;
      // Each stamp lands at its own slight angle, like a real hand stamp.
      el.querySelectorAll('.skc-stamp svg').forEach(function (svg, i) { svg.style.transform = 'rotate(' + ((i * 37 % 30) - 15) + 'deg)'; });
      el.querySelector('.skc-reward b').textContent = d.reward || DEFAULTS.reward;
      var s = el._skc = {
        el: el, grid: el.querySelector('.skc-grid'), countEl: el.querySelector('.skc-count'),
        slots: el.querySelectorAll('.skc-slot'), total: total,
        start: Math.min(total, +d.start || 0), n: 0, busy: false, visible: false
      };
      s.target = d.count !== undefined ? Math.min(total, +d.count) : total;
      reset(s);
      if (reduced()) {
        for (var k = s.n; k < s.target; k++) show(s, k);
        s.n = Math.max(s.n, s.target);
        label(s);
        return;
      }
      new IntersectionObserver(function (entries) {
        s.visible = entries[0].isIntersecting;
        if (s.visible && !s.busy && (!s.played || s.waiting)) { s.waiting = false; setTimeout(function () { run(el); }, 300); }
      }, { threshold: 0.3 }).observe(el);
    }

    function label(s) {
      s.countEl.textContent = s.n >= s.total ? 'Reward unlocked!' : s.n + ' / ' + s.total;
      s.slots[s.total - 1].classList.toggle('skc-won', s.n >= s.total);
    }

    function reset(s) {
      s.n = s.start;
      s.slots.forEach(function (slot, i) {
        anime.utils.set(slot.querySelector('.skc-stamp'), { opacity: i < s.start ? 1 : 0, scale: 1, y: 0, rotate: 0 });
      });
      label(s);
    }

    function show(s, i) {
      anime.utils.set(s.slots[i].querySelector('.skc-stamp'), { opacity: 1 });
    }

    // One stamp: drops from above, squashes on impact, ink ring, card jolts.
    function thump(s, done) {
      var i = s.n, slot = s.slots[i];
      if (i >= s.total) { if (done) done(); return; }
      s.n++;
      var stamp = slot.querySelector('.skc-stamp'), ring = slot.querySelector('.skc-ring');
      var tl = anime.createTimeline({
        onComplete: function () { if (done) done(); }
      });
      tl.add(stamp, { opacity: [0, 1], scale: [2.6, 1], y: ['-.6em', 0], rotate: [-25, 0], duration: 230, ease: 'in(3)' }, 0)
        .add(stamp, { scaleX: [1.15, 1], scaleY: [0.85, 1], duration: 300, ease: 'outElastic(1, .5)' }, 230)
        .add(ring, { opacity: [0.6, 0], scale: [0.9, 1.7], duration: 450, ease: 'out(3)' }, 230)
        .add(s.el, { y: [0, 3, -1, 0], duration: 260, ease: 'out(2)' }, 230)
        .call(function () {
          label(s);
          s.el.dispatchEvent(new CustomEvent('stamp', { bubbles: true, detail: { count: s.n } }));
          if (s.n >= s.total) anime.animate(slot, { scale: [1, 1.18, 1], duration: 600, ease: 'out(3)' });
        }, 230);
    }

    function run(el) {
      var s = el._skc, d = el.dataset;
      if (s.busy) return;
      s.busy = true;
      s.played = true;
      var speed = +d.speed || DEFAULTS.speed;
      var next = function () {
        if (s.n >= s.target) {
          s.busy = false;
          if (on(d.loop)) setTimeout(function () { clear(el); }, DEFAULTS.hold);
          return;
        }
        thump(s, function () { setTimeout(next, Math.max(0, speed - 530)); });
      };
      next();
    }

    function clear(el) {
      var s = el._skc;
      if (!s.visible) { s.waiting = true; reset(s); return; }
      s.busy = true;
      var stamps = [];
      for (var i = s.start; i < s.total; i++) stamps.push(s.slots[i].querySelector('.skc-stamp'));
      s.slots[s.total - 1].classList.remove('skc-won');
      anime.animate(stamps, {
        opacity: 0, scale: 0.6, duration: 300, delay: anime.stagger(40, { from: 'last' }), ease: 'in(2)',
        onComplete: function () {
          reset(s);
          s.busy = false;
          setTimeout(function () { if (s.visible) run(el); else s.waiting = true; }, 600);
        }
      });
    }

    window.animacious = window.animacious || {};
    window.animacious.stampCard = function (el) {
      var s = el && el._skc;
      if (!s || s.busy || s.n >= s.total) return;
      if (reduced()) { show(s, s.n); s.n++; label(s); return; }
      thump(s);
    };

    function init() {
      document.querySelectorAll('[data-stamp-card]').forEach(function (el) {
        if (el._skc) return;
        setup(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-stamp-card data-total="8" data-start="3" data-count="4" data-icon="cup">Coffee Club</div>