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
- Paste the snippet just before
</head>. - Add
data-stamp-cardto an element with the card’s title as its text, and the number of slots indata-total. - Set
data-startto the stamps the customer already has anddata-countto where it should get to, or callanimacious.stampCard(element)to add one.
Options
Add any of these attributes to the same element.
data-total | Number of slots, the last one being the reward. Default 8. |
data-start | Stamps already on the card. Default 0. |
data-count | How many it stamps up to. Default all of them. |
data-icon | star, cup or heart. Default star. |
data-reward | Text in the reward slot. Default “Free”. |
data-color | Ink colour. Default #6a0dad. |
data-speed | Time between stamps. Default 650 ms. |
data-loop | Clear 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.
<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><div data-stamp-card data-total="8" data-start="3" data-count="4" data-icon="cup">Coffee Club</div>