← E-commerce

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

  1. Paste the snippet just before </head>.
  2. Add data-scratch-card to 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-labelText printed on the foil. Default “Scratch here”.
data-colorFoil colour. Default silver, #b9bccb.
data-brushHow wide the scratch is. Default 22 px.
data-revealHow much must be scratched before the rest lifts off, from 0 to 1. Default 0.5.
data-autoplayA coin scratches it by itself while in view, until someone has a go. Handy for demos.
data-loopPut 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.

1 · Snippet
<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>
2 · Your HTML
<div class="voucher" data-scratch-card>
  <b>20% off</b>
  <span>your next order</span>
</div>