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.
Sprite★ 2
Lynx★ 3
Dragon★ 9
Hare★ 4
Owl★ 5
Tap to open another pack
How to use it
- Paste the snippet just before
</head>. - Add
data-card-pack-openingto a box with a height, holding your cards. Give the cards a size in CSS. - The pack and the card backs are drawn for you. Add
data-rareto a card to give it a shine.
Options
Add any of these attributes to the same element.
data-label | Text on the pack. Default “Booster”. |
data-pack | A colour or CSS background for the foil. Default a purple foil. |
data-back | A colour or CSS background for the card backs. |
data-stagger | Gap between cards as they fan out. Default 140 ms. |
data-loop | Gather the cards back into a fresh pack and open it again. |
data-hold | How 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.
<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><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>