Card Deal
The cards start as a neat face-down deck, then fly off the top one by one into a fan, turning face up as they land. Loop it and they’re swept back into the deck, which gives a little shuffle before dealing again.
A
♠♠A
♠
♠♠A
♠
K
♥♥K
♥
♥♥K
♥
Q
♦♦Q
♦
♦♦Q
♦
J
♣♣J
♣
♣♣J
♣
10
♥♥10
♥
♥♥10
♥
Click to deal again
How to use it
- Paste the snippet just before
</head>. - Add
data-card-dealto a box with a height, holding your cards. Give the cards a size in CSS. - The backs are drawn for you, with the same rounded corners as your cards.
Options
Add any of these attributes to the same element.
data-layout | fan, row or grid. Default fan. |
data-from | Where the deck sits: center, top, bottom, left or right. Default center. |
data-stagger | Gap between cards. Default 220 ms. |
data-duration | Time each card flies for. Default 650 ms. |
data-flip="false" | Deal them face up. |
data-back | A colour or CSS background for the backs. Default a purple pattern. |
data-loop | Gather, shuffle and deal again. |
data-hold | How long the spread stays before it’s gathered. Default 2200 ms. |
It deals when it scrolls into view, and a click deals again. The spread fits itself to the box’s width. From code: animacious.deal(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 Deal: the cards start as a face-down deck, then are dealt out one
// by one into a fan, a row or a grid, each flying off the top of the deck
// and turning face up as it lands. Loop it and they're swept back into the
// deck, which gives a little shuffle before dealing again.
// Usage: <div data-card-deal style="height: 260px">
// <div class="card">A♠</div> <div class="card">K♥</div> …
// </div>
// The children are the card faces; give them a size in CSS. The backs are
// drawn for you.
// Optional: data-layout ("fan", default, "row" or "grid"), data-from
// (where the deck sits: "center", default, "top", "bottom", "left" or
// "right"), data-stagger (ms between cards, default 220), data-duration
// (ms each card flies, default 650), data-flip="false" (deal face up),
// data-back (a colour or CSS background for the backs), data-loop,
// data-hold (ms to show the spread before gathering, default 2200).
// A click on it deals again. Script use: animacious.deal(el).
var DEFAULTS = { stagger: 220, duration: 650, hold: 2200 };
var css =
'.cdd{position:relative;-webkit-tap-highlight-color:transparent}' +
'.cdd-card{position:absolute;left:50%;top:50%;perspective:900px;will-change:transform}' +
'.cdd-in{position:relative;width:100%;height:100%;transform-style:preserve-3d}' +
'.cdd-in>*{position:absolute!important;inset:0;margin:0!important;-webkit-backface-visibility:hidden;backface-visibility:hidden;box-sizing:border-box}' +
'.cdd-back{transform:rotateY(180deg);border-radius:8px;border:4px solid #fff;background:repeating-linear-gradient(45deg,rgba(255,255,255,.14) 0 4px,transparent 4px 9px),linear-gradient(135deg,#8b2fd6,#4c0a8f);box-shadow:0 1px 3px rgba(0,0,0,.25)}' +
'.cdd-back::after{content:"";position:absolute;inset:10%;border:1.5px solid rgba(255,255,255,.45);border-radius:4px}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
function setup(el) {
var d = el.dataset;
var faces = Array.prototype.slice.call(el.children);
if (!faces.length) return;
el.classList.add('cdd');
var cards = faces.map(function (face) {
var w = face.offsetWidth || 70, h = face.offsetHeight || 100;
var card = document.createElement('div');
card.className = 'cdd-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 = 'cdd-in';
var back = document.createElement('div');
back.className = 'cdd-back';
back.setAttribute('aria-hidden', 'true');
if (d.back) back.style.background = d.back;
// Match the face's corner radius.
var br = getComputedStyle(face).borderRadius;
if (br && br !== '0px') back.style.borderRadius = br;
inner.appendChild(face);
inner.appendChild(back);
card.appendChild(inner);
el.appendChild(card);
return { el: card, inner: inner, w: w, h: h };
});
var s = el._cddS = { cards: cards, busy: false, timer: 0, visible: false, round: 0 };
gather(el, true);
if (reduced()) { cards.forEach(function (c, i) { place(el, c, spot(el, i), 1); }); return; }
el.addEventListener('click', function () { deal(el); });
var played = false;
new IntersectionObserver(function (en) {
s.visible = en[0].isIntersecting;
if (s.visible && !played) { played = true; setTimeout(function () { deal(el); }, 300); }
if (s.visible && s.waiting) { s.waiting = false; deal(el); }
}, { threshold: 0.3 }).observe(el);
new ResizeObserver(function () {
if (s.busy || !s.dealt) return;
cards.forEach(function (c, i) { place(el, c, spot(el, i), 1); });
}).observe(el);
}
// Where the deck sits, and where card i lands.
function deck(el) {
var from = el.dataset.from || 'center', c = el._cddS.cards[0];
var W = el.clientWidth, H = el.clientHeight, m = 10;
var x = from === 'left' ? -W / 2 + c.w / 2 + m : from === 'right' ? W / 2 - c.w / 2 - m : 0;
var y = from === 'top' ? -H / 2 + c.h / 2 + m : from === 'bottom' ? H / 2 - c.h / 2 - m : 0;
return { x: x, y: y, r: 0 };
}
function spot(el, i) {
var s = el._cddS, n = s.cards.length, c = s.cards[i];
var W = el.clientWidth, H = el.clientHeight;
var layout = el.dataset.layout || 'fan';
var mid = i - (n - 1) / 2;
if (layout === 'grid') {
var cols = Math.max(1, Math.min(n, Math.floor((W + 10) / (c.w + 10))));
var rows = Math.ceil(n / cols);
var gx = Math.min(c.w + 14, (W - c.w) / Math.max(1, cols - 1)), gy = Math.min(c.h + 14, (H - c.h) / Math.max(1, rows - 1));
var col = i % cols, row = Math.floor(i / cols);
var inRow = row === rows - 1 ? n - row * cols : cols;
return { x: (col - (inRow - 1) / 2) * gx, y: (row - (rows - 1) / 2) * gy, r: 0 };
}
var step = Math.min(c.w * 1.08, (W - c.w - 8) / Math.max(1, n - 1));
if (layout === 'row') return { x: mid * step, y: 0, r: 0 };
// Fan: along an arc, each card turned out from a point below.
var spread = Math.min(14, 70 / n);
var a = mid * spread, rad = a * Math.PI / 180;
var R = Math.max(c.h * 1.6, step / Math.sin(spread * Math.PI / 180));
var drop = (1 - Math.cos((n - 1) / 2 * spread * Math.PI / 180)) * R;
return { x: Math.sin(rad) * R * 0.92, y: (1 - Math.cos(rad)) * R - drop / 2, r: a };
}
function place(el, c, p, face) {
anime.utils.set(c.el, { x: p.x, y: p.y, rotate: p.r, scale: 1 });
anime.utils.set(c.inner, { rotateY: face ? 0 : 180 });
}
function gather(el, instant) {
var s = el._cddS, dk = deck(el), n = s.cards.length;
var flip = el.dataset.flip !== 'false';
s.cards.forEach(function (c, i) {
// A neat pile, slightly offset so it reads as a deck. The first card dealt is on top.
var p = { x: dk.x - (n - 1 - i) * 0.6, y: dk.y - (n - 1 - i) * 0.9, r: ((i * 37) % 7 - 3) * 0.5 };
c.el.style.zIndex = n - i;
c.home = p;
if (instant) place(el, c, p, !flip);
});
}
function deal(el) {
var s = el._cddS, d = el.dataset;
if (!s || s.busy) return;
s.busy = true;
clearTimeout(s.timer);
var round = ++s.round;
var stagger = d.stagger !== undefined ? +d.stagger : DEFAULTS.stagger;
var dur = +d.duration || DEFAULTS.duration;
var flip = d.flip !== 'false';
var n = s.cards.length;
var tl = anime.createTimeline({ autoplay: false });
if (s.dealt) {
// Sweep everything back into the deck, last card first, then shuffle.
gather(el, false);
s.cards.slice().reverse().forEach(function (c, k) {
tl.add(c.el, { x: c.home.x, y: c.home.y, rotate: c.home.r, duration: 480, ease: 'inOut(3)' }, k * 50);
if (flip) tl.add(c.inner, { rotateY: 180, duration: 380, ease: 'inOut(2)' }, k * 50);
});
var t0 = 480 + n * 50;
s.cards.forEach(function (c, i) {
var dir = i % 2 ? 1 : -1;
tl.add(c.el, {
x: [c.home.x, c.home.x + dir * c.w * 0.38, c.home.x],
rotate: [c.home.r, c.home.r + dir * 4, c.home.r],
duration: 520, ease: 'inOut(2)'
}, t0 + (i % 3) * 30);
});
}
var start = s.dealt ? 480 + n * 50 + 700 : 0;
s.cards.forEach(function (c, i) {
var p = spot(el, i), at = start + i * stagger;
tl.call(function () { c.el.style.zIndex = 100 + i; }, at);
tl.add(c.el, {
x: { from: c.home.x, to: p.x },
y: { from: c.home.y, to: p.y },
rotate: { from: c.home.r, to: p.r },
scale: [1, 1.1, 1],
duration: dur, ease: 'out(3)'
}, at);
if (flip) tl.add(c.inner, { rotateY: { from: 180, to: 0 }, duration: dur * 0.8, ease: 'inOut(2)' }, at + dur * 0.25);
});
tl.call(function () {
if (round !== s.round) return;
s.busy = false;
s.dealt = true;
if (d.loop !== undefined) s.timer = setTimeout(function () { if (s.visible) deal(el); else s.waiting = true; }, d.hold !== undefined ? +d.hold : DEFAULTS.hold);
}, start + (n - 1) * stagger + dur);
tl.play();
}
function init() {
document.querySelectorAll('[data-card-deal]').forEach(function (el) {
if (el._cdd) return;
el._cdd = true;
setup(el);
});
}
window.animacious = window.animacious || {};
window.animacious.deal = deal;
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-card-deal data-loop style="height: 260px">
<div class="card">A ♠</div>
<div class="card">K ♥</div>
<div class="card">Q ♦</div>
<div class="card">J ♣</div>
</div>