← Cards & images

Card Pack Opening

A shiny foil pack gives a wiggle and tears open along a jagged line, and the strip flies off. The cards slide up out of it, the empty pack drops away, and they fan out, turning face up one after another. A rare card catches the light as it lands.

Tap to open another pack

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-card-pack-opening to a box with a height, holding your cards. Give the cards a size in CSS.
  3. The pack and the card backs are drawn for you. Add data-rare to a card to give it a shine.

Options

Add any of these attributes to the same element.

data-labelText on the pack. Default “Booster”.
data-packA colour or CSS background for the foil. Default a purple foil.
data-backA colour or CSS background for the card backs.
data-staggerGap between cards as they fan out. Default 140 ms.
data-loopGather the cards back into a fresh pack and open it again.
data-holdHow long the fan stays before the pack closes. Default 2600 ms.

It opens when it scrolls into view, and a click or tap opens it again. The fan fits itself to the box’s width. From code: animacious.openPack(element). With reduced motion the cards are simply laid out.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Card Pack Opening: a shiny foil pack gives a wiggle, tears open along a
    // jagged line (the strip flies off), the cards slide up out of it and fan
    // out, turning face up one after another. Mark a card as rare and it
    // catches the light as it lands.
    // Usage: <div data-card-pack-opening data-label="Series 1" style="height: 320px">
    //          <div class="card">…</div> <div class="card" data-rare>…</div> …
    //        </div>
    // The children are the card faces; give them a size in CSS. The pack and
    // the card backs are drawn for you.
    // Optional: data-label (text on the pack), data-pack (a colour or CSS
    // background for the foil), data-back (a colour or CSS background for the
    // card backs), data-stagger (ms between cards, default 140), data-loop,
    // data-hold (ms the fan stays before the pack closes again, default 2600).
    // A click or tap opens it again. Script use: animacious.openPack(el).
    var DEFAULTS = { stagger: 140, hold: 2600 };
    var TEETH = 9, TEAR = 17;

    var css =
      '.cpo{position:relative;-webkit-tap-highlight-color:transparent;cursor:pointer}' +
      '.cpo>.cpo-card,.cpo>.cpo-pack{position:absolute;left:50%;top:50%}' +
      '.cpo-card{perspective:900px;will-change:transform}' +
      '.cpo-in{position:relative;width:100%;height:100%;transform-style:preserve-3d}' +
      '.cpo-in>*{position:absolute!important;inset:0;margin:0!important;-webkit-backface-visibility:hidden;backface-visibility:hidden;box-sizing:border-box;overflow:hidden}' +
      '.cpo-back{transform:rotateY(180deg);border-radius:8px;border:3px solid #fff;background:radial-gradient(circle at 50% 50%,rgba(255,255,255,.35) 0 14%,transparent 15%),repeating-linear-gradient(45deg,rgba(255,255,255,.12) 0 4px,transparent 4px 9px),linear-gradient(135deg,#f59e0b,#db2777 55%,#6a0dad);box-shadow:0 1px 3px rgba(0,0,0,.25)}' +
      '.cpo-glint{position:absolute;inset:0;z-index:2;background:linear-gradient(110deg,transparent 35%,rgba(255,255,255,.85) 50%,transparent 65%);transform:translateX(-120%);pointer-events:none;mix-blend-mode:overlay}' +
      '.cpo-pack{z-index:50;pointer-events:none}' +
      '.cpo-pack>span{position:absolute;inset:0;display:grid;place-items:center;align-content:center;gap:4%;border-radius:4px;background:var(--cpo-foil);color:#fff;text-align:center;font-weight:800;letter-spacing:-.01em;text-shadow:0 1px 2px rgba(0,0,0,.35)}' +
      '.cpo-pack>span::before{content:"";position:absolute;inset:0;border-radius:inherit;background:repeating-linear-gradient(90deg,rgba(255,255,255,.25) 0 2px,transparent 2px 5px) top/100% 6% no-repeat,repeating-linear-gradient(90deg,rgba(255,255,255,.25) 0 2px,transparent 2px 5px) bottom/100% 5% no-repeat,linear-gradient(115deg,transparent 30%,rgba(255,255,255,.4) 45%,transparent 60%) var(--cpo-shine,-100%) 0/220% 100% no-repeat}' +
      '.cpo-pack b{position:relative;font-size:1.3em;line-height:1.05}' +
      '.cpo-pack i{position:relative;font-style:normal;font-size:.62em;font-weight:700;opacity:.85;text-transform:uppercase;letter-spacing:.12em}' +
      '.cpo-spark{position:absolute;z-index:51;width:14px;height:14px;margin:-7px 0 0 -7px;border-radius:50%;background:radial-gradient(#fff 20%,#fde68a 45%,transparent 70%);opacity:0;pointer-events:none}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }

    // The jagged tear line, as clip-path polygons for the top strip and the body.
    function tear() {
      var pts = [];
      for (var i = 0; i <= TEETH * 2; i++) pts.push((i / (TEETH * 2) * 100).toFixed(2) + '% ' + (TEAR + (i % 2 ? 3 : -2)) + '%');
      return {
        top: 'polygon(0 0,100% 0,' + pts.slice().reverse().join(',') + ')',
        body: 'polygon(' + pts.join(',') + ',100% 100%,0 100%)'
      };
    }

    function setup(el) {
      var d = el.dataset;
      var faces = Array.prototype.slice.call(el.children);
      if (!faces.length) return;
      el.classList.add('cpo');
      var w = faces[0].offsetWidth || 70, h = faces[0].offsetHeight || 100;
      var cards = faces.map(function (face) {
        var card = document.createElement('div');
        card.className = 'cpo-card';
        card.style.width = w + 'px';
        card.style.height = h + 'px';
        card.style.margin = (-h / 2) + 'px 0 0 ' + (-w / 2) + 'px';
        var inner = document.createElement('div');
        inner.className = 'cpo-in';
        var back = document.createElement('div');
        back.className = 'cpo-back';
        back.setAttribute('aria-hidden', 'true');
        if (d.back) back.style.background = d.back;
        var br = getComputedStyle(face).borderRadius;
        if (br && br !== '0px') back.style.borderRadius = br;
        inner.appendChild(face);
        inner.appendChild(back);
        var glint = null;
        if (face.hasAttribute('data-rare')) {
          glint = document.createElement('span');
          glint.className = 'cpo-glint';
          glint.setAttribute('aria-hidden', 'true');
          face.style.position = 'relative';
          face.appendChild(glint);
        }
        card.appendChild(inner);
        el.appendChild(card);
        return { el: card, inner: inner, glint: glint };
      });

      var pw = Math.round(w * 1.2), ph = Math.round(h * 1.3);
      var pack = document.createElement('div');
      pack.className = 'cpo-pack';
      pack.setAttribute('aria-hidden', 'true');
      pack.style.cssText = 'width:' + pw + 'px;height:' + ph + 'px;margin:' + (-ph / 2) + 'px 0 0 ' + (-pw / 2) + 'px;font-size:' + Math.round(pw * 0.11) + 'px;--cpo-foil:' +
        (d.pack || 'linear-gradient(160deg,#c084fc,#7c3aed 35%,#4c0a8f 70%,#a855f7)');
      var label = '<b>' + (d.label || 'Booster').replace(/[<&]/g, '') + '</b><i>' + cards.length + ' cards</i>';
      var cut = tear();
      var body = document.createElement('span');
      body.innerHTML = label;
      body.style.clipPath = cut.body;
      var top = document.createElement('span');
      top.innerHTML = label;
      top.style.clipPath = cut.top;
      pack.appendChild(body);
      pack.appendChild(top);
      el.appendChild(pack);
      var spark = document.createElement('span');
      spark.className = 'cpo-spark';
      spark.setAttribute('aria-hidden', 'true');
      el.appendChild(spark);

      var s = el._cpoS = { cards: cards, pack: pack, body: body, top: top, spark: spark, w: w, h: h, pw: pw, ph: ph, tl: null, timer: 0, visible: false, open: false };
      if (reduced()) {
        anime.utils.set(pack, { opacity: 0 });
        cards.forEach(function (c, i) { var p = spot(el, i); anime.utils.set(c.el, p); });
        return;
      }
      cards.forEach(function (c, i) { anime.utils.set(c.inner, { rotateY: 180 }); c.el.style.zIndex = i + 1; });
      anime.utils.set(pack, { opacity: 0, scale: 0.8, y: 20 });

      el.addEventListener('click', function () { play(el); });
      var played = false;
      new IntersectionObserver(function (en) {
        s.visible = en[0].isIntersecting;
        if (s.visible && !played) { played = true; play(el); }
        if (s.visible && s.waiting) { s.waiting = false; play(el); }
      }, { threshold: 0.3 }).observe(el);
      new ResizeObserver(function () {
        if (s.busy || !s.open) return;
        cards.forEach(function (c, i) { anime.utils.set(c.el, spot(el, i)); });
      }).observe(el);
    }

    // Where card i ends up: along a gentle arc, turned out from below.
    function spot(el, i) {
      var s = el._cpoS, n = s.cards.length, W = el.clientWidth;
      var mid = i - (n - 1) / 2;
      var step = Math.min(s.w * 0.78, (W - s.w - 8) / Math.max(1, n - 1));
      var a = mid * Math.min(12, 50 / n);
      return { x: mid * step, y: Math.abs(mid) * Math.abs(mid) * s.h * 0.035 + s.h * 0.02, rotate: a };
    }

    function play(el) {
      var s = el._cpoS, d = el.dataset;
      if (!s || reduced() || s.busy) return;
      s.busy = true;
      clearTimeout(s.timer);
      var n = s.cards.length, stagger = d.stagger !== undefined ? +d.stagger : DEFAULTS.stagger;
      var tl = s.tl = anime.createTimeline();
      var t = 0;
      var tearY = -s.ph / 2 + s.ph * TEAR / 100;

      if (s.open) {
        // Close up: the cards turn over and slide back in, the pack comes back.
        s.cards.forEach(function (c, i) {
          tl.add(c.inner, { rotateY: 180, duration: 400, ease: 'inOut(2)' }, i * 40);
          tl.add(c.el, { x: 0, y: -s.ph * 0.45, rotate: 0, duration: 500, ease: 'inOut(3)' }, 200 + i * 40);
        });
        tl.call(function () { s.cards.forEach(function (c, i) { c.el.style.zIndex = i + 1; }); }, 760 + n * 40);
        tl.add(s.body, { y: [s.ph * 0.6, 0], opacity: [0, 1], duration: 450, ease: 'out(3)' }, 720 + n * 40);
        tl.add(s.cards.map(function (c) { return c.el; }), { y: 0, duration: 380, ease: 'in(2)' }, 1000 + n * 40);
        tl.add(s.top, { x: 0, y: 0, rotate: 0, opacity: 1, duration: 450, ease: 'out(3)' }, 1250 + n * 40);
        t = 1900 + n * 40;
      } else {
        tl.add(s.pack, { opacity: 1, scale: 1, y: 0, duration: 600, ease: 'outBack(1.8)' }, 0);
        t = 700;
      }

      // A wiggle, and the foil catches the light.
      tl.add(s.pack, { rotate: [0, -4, 4, -2, 0], duration: 600, ease: 'inOut(2)' }, t);
      tl.add(s.pack, { '--cpo-shine': ['-100%', '200%'], duration: 700, ease: 'inOut(2)' }, t);
      t += 650;

      // Tear along the line: a spark runs across, the strip flies off.
      tl.set(s.spark, { x: -s.pw / 2, y: tearY, opacity: 1 }, t);
      tl.add(s.spark, { x: s.pw / 2, duration: 420, ease: 'inOut(2)' }, t);
      tl.add(s.spark, { opacity: 0, scale: [1, 2], duration: 200 }, t + 420);
      tl.add(s.top, { x: s.pw * 0.55, y: -s.ph * 0.35, rotate: 28, opacity: 0, duration: 650, ease: 'out(3)' }, t + 380);
      t += 650;

      // The cards slide up out of the pack, then the pack drops away.
      tl.add(s.cards.map(function (c) { return c.el; }), { y: -s.ph * 0.45, duration: 480, delay: anime.stagger(50), ease: 'out(3)' }, t);
      t += 420;
      tl.add(s.body, { y: s.ph * 0.6, opacity: 0, duration: 500, ease: 'in(2)' }, t);
      tl.call(function () { s.cards.forEach(function (c, i) { c.el.style.zIndex = 60 + i; }); }, t + 150);

      // Fan out and turn face up, one by one.
      t += 200;
      s.cards.forEach(function (c, i) {
        var p = spot(el, i), at = t + i * stagger;
        tl.add(c.el, { x: p.x, y: p.y, rotate: p.rotate, duration: 700, ease: 'outBack(1.4)' }, at);
        tl.add(c.inner, { rotateY: 0, duration: 520, ease: 'inOut(2)' }, at + 120);
        if (c.glint) tl.add(c.glint, { x: ['-120%', '120%'], duration: 900, ease: 'inOut(2)' }, at + 600);
      });
      var end = t + (n - 1) * stagger + 900;
      tl.call(function () {
        s.busy = false;
        s.open = true;
        if (d.loop !== undefined) s.timer = setTimeout(function () {
          if (s.visible) play(el); else s.waiting = true;
        }, d.hold !== undefined ? +d.hold : DEFAULTS.hold);
      }, end);
    }

    function init() {
      document.querySelectorAll('[data-card-pack-opening]').forEach(function (el) {
        if (el._cpo) return;
        el._cpo = true;
        setup(el);
      });
    }

    window.animacious = window.animacious || {};
    window.animacious.openPack = play;
    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-card-pack-opening data-label="Series 1" style="height: 320px">
  <div class="card">…</div>
  <div class="card">…</div>
  <div class="card" data-rare>…</div>
  <div class="card">…</div>
  <div class="card">…</div>
</div>