Scratch Card
A silver foil hides the prize. Rub it with the mouse or a finger and it scratches away under the pointer; once about half is gone, the rest of the foil lifts off and the prize pops into view.
You won
20% off
your next order
How to use it
- Paste the snippet just before
</head>. - Add
data-scratch-cardto a box holding the prize. Give the box a size and background: the foil covers it exactly.
Options
Add any of these attributes to the same element.
data-label | Text printed on the foil. Default “Scratch here”. |
data-color | Foil colour. Default silver, #b9bccb. |
data-brush | How wide the scratch is. Default 22 px. |
data-reveal | How much must be scratched before the rest lifts off, from 0 to 1. Default 0.5. |
data-autoplay | A coin scratches it by itself while in view, until someone has a go. Handy for demos. |
data-loop | Put the foil back a couple of seconds after the reveal. |
It fires a scratch event once revealed, and animacious.scratchCard(element) puts the foil back on. The prize is real text underneath, so screen readers can read it straight away.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Scratch Card: a silver foil covers the prize. Rub it with the mouse or a
// finger and it scratches away; once enough is gone, the rest of the foil
// lifts off and the prize pops.
// Usage: <div data-scratch-card>20% off your next order</div>
// Optional: data-label (text on the foil, default "Scratch here"),
// data-color (foil colour, default #b9bccb), data-brush (scratch width in
// px, default 22), data-reveal (how much must be scratched before the rest
// lifts off, 0 to 1, default 0.5), data-autoplay (a coin scratches it by
// itself while in view, until someone has a go; handy for demos),
// data-loop (cover it again a little after the reveal).
// Fires a "scratch" event once revealed. Script use:
// animacious.scratchCard(el) puts the foil back on.
var DEFAULTS = { label: 'Scratch here', color: '#b9bccb', brush: 22, reveal: 0.5, hold: 2200 };
var css =
'.scr{position:relative;overflow:hidden;isolation:isolate;-webkit-user-select:none;user-select:none}' +
'.scr-foil{position:absolute;inset:0;width:100%;height:100%;z-index:2;touch-action:none;cursor:grab;border-radius:inherit}' +
'.scr-foil:active{cursor:grabbing}' +
'.scr-coin{position:absolute;left:0;top:0;z-index:3;width:30px;height:30px;margin:-15px 0 0 -15px;border-radius:50%;pointer-events:none;opacity:0;' +
'background:radial-gradient(circle at 35% 30%,#fff3b8,#f5c542 45%,#c8961a);box-shadow:0 3px 8px rgba(0,0,0,.25),inset 0 0 0 3px rgba(255,255,255,.35)}';
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'; }
// The foil: a brushed-metal gradient, a scatter of glints and the label.
function paintFoil(s) {
var ctx = s.ctx, w = s.w, h = s.h;
ctx.setTransform(s.dpr, 0, 0, s.dpr, 0, 0);
ctx.globalCompositeOperation = 'source-over';
ctx.clearRect(0, 0, w, h);
ctx.fillStyle = s.color;
ctx.fillRect(0, 0, w, h);
var g = ctx.createLinearGradient(0, 0, w, h);
g.addColorStop(0, 'rgba(255,255,255,.55)');
g.addColorStop(0.35, 'rgba(255,255,255,0)');
g.addColorStop(0.55, 'rgba(255,255,255,.35)');
g.addColorStop(0.7, 'rgba(0,0,0,.06)');
g.addColorStop(1, 'rgba(255,255,255,.3)');
ctx.fillStyle = g;
ctx.fillRect(0, 0, w, h);
var n = Math.round((w * h) / 60);
for (var i = 0; i < n; i++) {
ctx.fillStyle = Math.random() < 0.5 ? 'rgba(255,255,255,.5)' : 'rgba(0,0,0,.07)';
ctx.fillRect(Math.random() * w, Math.random() * h, 1.2, 1.2);
}
var fs = Math.max(11, Math.min(h * 0.2, w * 0.085));
ctx.font = '700 ' + fs + 'px system-ui,-apple-system,Segoe UI,sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillStyle = 'rgba(30,30,50,.45)';
ctx.fillText(s.label, w / 2, h / 2);
ctx.fillStyle = 'rgba(255,255,255,.45)';
ctx.fillText(s.label, w / 2 - 1, h / 2 - 1);
}
function resize(s) {
var w = s.el.clientWidth, h = s.el.clientHeight;
if (!w || !h || (w === s.w && h === s.h)) return;
s.w = w;
s.h = h;
s.dpr = Math.min(2, window.devicePixelRatio || 1);
s.canvas.width = Math.round(w * s.dpr);
s.canvas.height = Math.round(h * s.dpr);
// A new size means a fresh foil (unless it has already come off).
if (!s.revealed) paintFoil(s);
}
function scratch(s, x0, y0, x1, y1) {
var ctx = s.ctx;
ctx.globalCompositeOperation = 'destination-out';
ctx.lineCap = ctx.lineJoin = 'round';
ctx.lineWidth = s.brush;
ctx.beginPath();
ctx.moveTo(x0, y0);
ctx.lineTo(x1 + 0.01, y1);
ctx.stroke();
if (++s.strokes % 6 === 0) check(s);
}
// How much of the foil is gone, sampled on a coarse grid.
function check(s) {
if (s.revealed) return;
var cw = s.canvas.width, ch = s.canvas.height;
var data = s.ctx.getImageData(0, 0, cw, ch).data;
var step = Math.max(4, Math.round(8 * s.dpr)), clear = 0, total = 0;
for (var y = 0; y < ch; y += step) {
for (var x = 0; x < cw; x += step) {
total++;
if (data[(y * cw + x) * 4 + 3] < 128) clear++;
}
}
if (total && clear / total >= s.reveal) reveal(s);
}
function reveal(s) {
s.revealed = true;
s.dragging = false;
if (s.auto) { s.auto.pause(); s.auto = null; }
anime.animate(s.coin, { opacity: 0, duration: 200 });
if (reduced()) {
s.canvas.style.opacity = 0;
} else {
anime.animate(s.canvas, { opacity: 0, scale: 1.08, duration: 450, ease: 'out(2)' });
anime.animate(s.prize, { scale: [0.82, 1.06, 1], duration: 650, ease: 'out(3)' });
}
s.canvas.style.pointerEvents = 'none';
s.el.dispatchEvent(new CustomEvent('scratch', { bubbles: true }));
if (s.loop) setTimeout(function () { cover(s); }, s.hold);
}
function cover(s) {
s.revealed = false;
s.strokes = 0;
paintFoil(s);
s.canvas.style.pointerEvents = '';
anime.animate(s.canvas, { opacity: [0, 1], scale: [1.04, 1], duration: 450, ease: 'out(2)' });
setTimeout(function () { if (s.visible) autoplay(s); }, 900);
}
// A coin zigzags across the card, scratching as it goes.
function autoplay(s) {
if (!s.autoOn || s.user || s.revealed || s.auto || reduced()) return;
var pts = [], rows = 7;
for (var i = 0; i <= rows; i++) {
pts.push([(i % 2 ? 0.88 : 0.12) * s.w, (0.14 + (0.72 * i) / rows) * s.h]);
}
var segs = pts.length - 1, prog = { t: 0 }, last = null;
anime.utils.set(s.coin, { x: pts[0][0], y: pts[0][1] });
anime.animate(s.coin, { opacity: 1, duration: 200 });
s.auto = anime.animate(prog, {
t: 1,
duration: 2600,
ease: 'inOut(1.3)',
onUpdate: function () {
var f = prog.t * segs, k = Math.min(segs - 1, Math.floor(f)), u = f - k;
var x = pts[k][0] + (pts[k + 1][0] - pts[k][0]) * u;
var y = pts[k][1] + (pts[k + 1][1] - pts[k][1]) * u;
anime.utils.set(s.coin, { x: x, y: y, rotate: prog.t * 720 });
if (last) scratch(s, last[0], last[1], x, y);
last = [x, y];
},
onComplete: function () {
s.auto = null;
if (!s.revealed) reveal(s);
}
});
}
function setup(el) {
var d = el.dataset;
el.classList.add('scr');
var prize = document.createElement('div');
prize.className = 'scr-prize';
while (el.firstChild) prize.appendChild(el.firstChild);
el.appendChild(prize);
var canvas = document.createElement('canvas');
canvas.className = 'scr-foil';
canvas.setAttribute('aria-hidden', 'true');
el.appendChild(canvas);
var coin = document.createElement('span');
coin.className = 'scr-coin';
coin.setAttribute('aria-hidden', 'true');
el.appendChild(coin);
var s = el._scr = {
el: el, prize: prize, canvas: canvas, coin: coin, ctx: canvas.getContext('2d', { willReadFrequently: true }),
w: 0, h: 0, dpr: 1, strokes: 0, revealed: false, visible: false,
label: d.label || DEFAULTS.label,
color: d.color || DEFAULTS.color,
brush: +d.brush || DEFAULTS.brush,
reveal: d.reveal !== undefined ? +d.reveal : DEFAULTS.reveal,
loop: on(d.loop) || on(d.autoplay),
autoOn: on(d.autoplay),
hold: DEFAULTS.hold
};
resize(s);
new ResizeObserver(function () { resize(s); }).observe(el);
var last = null;
function pos(e) {
var r = canvas.getBoundingClientRect();
return [e.clientX - r.left, e.clientY - r.top];
}
canvas.addEventListener('pointerdown', function (e) {
if (s.revealed) return;
s.user = true;
if (s.auto) { s.auto.pause(); s.auto = null; anime.animate(coin, { opacity: 0, duration: 200 }); }
s.dragging = true;
try { canvas.setPointerCapture(e.pointerId); } catch (err) {}
last = pos(e);
scratch(s, last[0], last[1], last[0], last[1]);
e.preventDefault();
});
canvas.addEventListener('pointermove', function (e) {
if (!s.dragging) return;
var p = pos(e);
scratch(s, last[0], last[1], p[0], p[1]);
last = p;
});
function up() {
if (!s.dragging) return;
s.dragging = false;
check(s);
}
canvas.addEventListener('pointerup', up);
canvas.addEventListener('pointercancel', up);
if (s.autoOn && reduced()) {
// No motion wanted: show the prize rather than a coin scratching away.
s.revealed = true;
canvas.style.opacity = 0;
canvas.style.pointerEvents = 'none';
}
new IntersectionObserver(function (entries) {
s.visible = entries[0].isIntersecting;
if (s.auto) { if (s.visible) s.auto.play(); else s.auto.pause(); }
else if (s.visible) setTimeout(function () { autoplay(s); }, 500);
}, { threshold: 0.3 }).observe(el);
}
window.animacious = window.animacious || {};
window.animacious.scratchCard = function (el) { if (el && el._scr) cover(el._scr); };
function init() {
document.querySelectorAll('[data-scratch-card]').forEach(function (el) {
if (el._scr) return;
setup(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div class="voucher" data-scratch-card>
<b>20% off</b>
<span>your next order</span>
</div>