Card Fan
A neat pile of cards fans out like a hand of playing cards when the pointer comes over it, and the card under the pointer lifts up for a closer look. Move away and they gather back into a pile.
Summer12 photos
TripAlbum
Point at the stack, then at a card
How to use it
- Paste the snippet just before
</head>. - Add
data-card-fanto a container whose children are the cards, and give the cards a size.
Options
Add any of these attributes to the same element.
data-angle | Degrees from the first card to the last when fanned. Default 40. |
data-spread | How far apart they spread, as a share of a card’s width. Default 0.3. |
data-trigger="click" | Fan out and back on click (or Enter and Space) instead of on hover. |
data-autoplay | Milliseconds: fan out and back on a timer while in view. A real pointer pauses it. |
It fans out when it gets keyboard focus too, and on touch screens a tap toggles it.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Card Fan: a neat stack of cards fans out like a hand of playing cards
// when the pointer comes over it (or it gets keyboard focus), and the card
// under the pointer lifts up. It gathers back into a stack on leave.
// Usage:
// <div data-card-fan>
// <img src="one.jpg" alt="…">
// <img src="two.jpg" alt="…">
// <img src="three.jpg" alt="…">
// </div>
// Each child is a card. Optional: data-angle (degrees from the first card
// to the last, default 40), data-spread (how far apart, as a share of a
// card's width, default 0.3), data-trigger="click" (fan out on click
// instead), data-autoplay (ms: fan out and back on a timer until someone
// interacts).
var DEFAULTS = { angle: 40, spread: 0.3, duration: 650 };
var css =
'[data-card-fan]{display:grid;justify-items:center;align-items:end}' +
'[data-card-fan]>*{grid-area:1/1;transform-origin:50% 140%;will-change:transform}' +
'.cfn-click{cursor:pointer}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function setup(el) {
var d = el.dataset;
var cards = Array.from(el.children);
if (!cards.length) return;
var reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var angle = d.angle !== undefined ? +d.angle : DEFAULTS.angle;
var spread = d.spread !== undefined ? +d.spread : DEFAULTS.spread;
var mid = (cards.length - 1) / 2;
var step = cards.length > 1 ? angle / (cards.length - 1) : 0;
var open = false, lifted = null, timer = null, stopped = false, paused = false;
var inThumb = !!el.closest('[aria-hidden="true"]');
function place(card, i, now) {
var o = i - mid, cw = card.offsetWidth;
var props = open
? { rotate: o * step, x: o * cw * spread, y: card === lifted ? -cw * 0.16 : 0, scale: card === lifted ? 1.05 : 1 }
// Stacked: a tiny untidy offset, so it reads as a pile.
: { rotate: o * 2.2, x: o * 1.5, y: -Math.abs(o) * 1.5, scale: 1 };
if (now || reduced) return anime.utils.set(card, props);
props.duration = DEFAULTS.duration;
props.ease = open ? 'outBack(1.3)' : 'out(4)';
// Fan out from the middle; gather in from the edges.
props.delay = (open ? Math.abs(o) : mid - Math.abs(o)) * 35;
anime.animate(card, props);
}
function layout(now) { cards.forEach(function (c, i) { place(c, i, now); }); }
function set(next) {
next = !!next;
if (next === open) return;
open = next;
if (!open) lifted = null;
if (d.trigger === 'click') el.setAttribute('aria-expanded', open);
layout(false);
}
function stop() { stopped = true; clearInterval(timer); timer = null; }
layout(true);
cards.forEach(function (card, i) {
card.addEventListener('pointerenter', function (e) {
if (e.pointerType === 'touch' || !open) return;
lifted = card;
place(card, i, false);
});
card.addEventListener('pointerleave', function () {
if (lifted !== card) return;
lifted = null;
if (open) place(card, i, false);
});
});
if (d.trigger === 'click') {
el.classList.add('cfn-click');
if (!inThumb) {
if (!el.hasAttribute('tabindex')) el.tabIndex = 0;
el.setAttribute('role', 'button');
el.setAttribute('aria-expanded', 'false');
if (!el.getAttribute('aria-label')) el.setAttribute('aria-label', 'Fan out the cards');
}
el.addEventListener('click', function () { stop(); set(!open); });
el.addEventListener('keydown', function (e) {
if (e.target !== el || (e.key !== 'Enter' && e.key !== ' ')) return;
e.preventDefault();
stop();
set(!open);
});
} else {
// A real pointer pauses the autoplay while it's over the cards.
el.addEventListener('pointerenter', function (e) { if (e.pointerType !== 'touch') { paused = true; set(true); } });
el.addEventListener('pointerleave', function (e) { if (e.pointerType !== 'touch') { paused = false; set(false); } });
// Touch screens have no hover: a tap fans them out or gathers them.
el.addEventListener('pointerup', function (e) { if (e.pointerType === 'touch') { stop(); set(!open); } });
// Keyboard users get the fan when focus lands in it.
if (!inThumb && !el.querySelector('a,button,input,select,textarea,[tabindex]') && !el.hasAttribute('tabindex')) {
el.tabIndex = 0;
if (!el.getAttribute('role')) el.setAttribute('role', 'group');
}
el.addEventListener('focusin', function () { stop(); set(true); });
el.addEventListener('focusout', function (e) { if (!el.contains(e.relatedTarget)) set(false); });
}
if (window.ResizeObserver) new ResizeObserver(function () { layout(true); }).observe(el);
if (d.autoplay !== undefined && d.autoplay !== 'false' && !reduced) {
var every = +d.autoplay || 1800;
new IntersectionObserver(function (entries) {
clearInterval(timer);
timer = null;
if (!entries[0].isIntersecting || stopped) return;
timer = setInterval(function () {
if (paused || el.closest('[hidden]')) return;
// Lift a card part-way through, as a pointer would.
set(!open);
if (open) setTimeout(function () {
if (!open || paused) return;
var i = Math.floor(Math.random() * cards.length);
lifted = cards[i];
place(lifted, i, false);
}, every * 0.45);
}, every);
}).observe(el);
}
}
function init() {
document.querySelectorAll('[data-card-fan]').forEach(function (el) {
if (el._cfn) return;
el._cfn = true;
setup(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-card-fan>
<img src="one.jpg" alt="Beach" width="150" height="200">
<img src="two.jpg" alt="Forest" width="150" height="200">
<img src="three.jpg" alt="City" width="150" height="200">
</div>