Clear green water, pine woods and snow on the peaks, even in August. Swim at dawn before the boats are out.
Expanding Card Carousel
A row of tall cards that slides and snaps sideways. Tap a card and it grows out of the row to fill the carousel, its details fading in; close it and it shrinks neatly back into its place.
Long winter nights and a sky that moves. Wrap up warm, look north and wait for the green to start dancing.
Climb the tallest dune before sunset and watch the sand turn from gold to rose to violet.
Neon, noodles and trains that run to the minute. The city really wakes up after dark.
Warm, shallow water and reefs a few strokes from the beach. Bring a mask and take your time.
How to use it
- Paste the snippet just before
</head>. - Add
data-expanding-card-carouseland anaria-labelto a container whose children are the cards. Give the cards a width, and the container a height: the open card fills it. - Mark anything that should only show when a card is open with
data-detail.
Options
Add any of these attributes to the same element.
data-autoplay | Milliseconds: slide along and now and then open a card, until someone uses it. |
data-duration | Time to open and close a card. Default 650 ms. |
data-controls="false" | No previous and next buttons. |
Swipe, scroll sideways or drag with the mouse to move along; a card at the side slides to the middle when tapped, and the middle one opens. The cards are buttons with aria-expanded, and Escape or the close button shuts the open one. From code: animacious.expandingCard(element, 2) opens a card and -1 closes it.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Expanding Card Carousel: a row of tall cards that slides and snaps
// sideways (swipe, drag, scroll or the arrow buttons). Tap or click a card
// and it grows out of the row into a full view; close it and it shrinks
// back into its place.
// Usage:
// <div data-expanding-card-carousel aria-label="Destinations">
// <article><img src="lake.jpg" alt=""><h3>Lake</h3><p data-detail>More…</p></article>
// <article>…</article>
// </div>
// Each child is a card; give the cards a width and a height. Anything
// marked data-detail inside a card is hidden in the row and fades in when
// the card is open. The open view fills the carousel, so give it a height.
// Optional: data-autoplay (ms between steps: it slides along and now and
// then opens a card, until someone uses it), data-duration (time to open
// and close, ms, default 650), data-controls="false" (no arrow buttons).
// Script use: animacious.expandingCard(element, index) opens a card;
// animacious.expandingCard(element, -1) closes it.
var DEFAULTS = { duration: 650 };
var CHEVRON = '<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M15 5l-7 7 7 7"/></svg>';
var CROSS = '<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><path d="M6 6l12 12M18 6L6 18"/></svg>';
var css =
'[data-expanding-card-carousel]{position:relative;overflow:hidden}' +
'.ecc-track{display:flex;align-items:center;overflow-x:auto;overflow-y:hidden;scroll-snap-type:x mandatory;scrollbar-width:none;overscroll-behavior-x:contain;user-select:none;-webkit-user-select:none}' +
'.ecc-track::-webkit-scrollbar{display:none}' +
'.ecc-track.ecc-drag{scroll-snap-type:none;cursor:grabbing}' +
'.ecc-track>*{flex:none;scroll-snap-align:center;cursor:pointer;transform-origin:50% 50%}' +
'.ecc-track>:focus-visible{outline:3px solid #a855f7;outline-offset:3px}' +
'.ecc-spacer{flex:none;align-self:stretch;pointer-events:none}' +
'[data-expanding-card-carousel] [data-detail]{display:none}' +
'.ecc-open{position:absolute!important;z-index:5;margin:0!important;cursor:default!important;transform:none!important}' +
'.ecc-open [data-detail]{display:block}' +
'.ecc-close{position:absolute;top:12px;right:12px;z-index:6;display:grid;place-items:center;width:38px;height:38px;border:0;border-radius:50%;background:rgba(255,255,255,.92);color:#1f1f24;box-shadow:0 4px 14px rgba(15,15,30,.2);cursor:pointer}' +
'.ecc-close[hidden]{display:none}' +
'.ecc-close:focus-visible{outline:3px solid #a855f7;outline-offset:2px}' +
'.ecc-nav{display:flex;justify-content:center;gap:14px;margin-top:6px}' +
'.ecc-nav button{display:grid;place-items:center;width:40px;height:40px;border:0;border-radius:50%;background:#fff;color:#1f1f24;box-shadow:0 4px 14px rgba(15,15,30,.12);cursor:pointer}' +
'.ecc-nav button:last-child svg{transform:scaleX(-1)}' +
'.ecc-nav button:focus-visible{outline:3px solid #a855f7;outline-offset:2px}';
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 cards = Array.from(el.children);
if (!cards.length) return;
var duration = +d.duration || DEFAULTS.duration;
var inThumb = !!el.closest('[aria-hidden="true"]');
var open = null, placeholder = null, busy = false, stopped = false, returnFocus = null;
var track = document.createElement('div');
track.className = 'ecc-track';
var lead = document.createElement('span'), tail = document.createElement('span');
lead.className = tail.className = 'ecc-spacer';
track.appendChild(lead);
cards.forEach(function (c) { track.appendChild(c); });
track.appendChild(tail);
el.appendChild(track);
var close = null;
if (!inThumb) {
el.setAttribute('role', 'region');
el.setAttribute('aria-roledescription', 'carousel');
cards.forEach(function (c) {
c.tabIndex = 0;
c.setAttribute('role', 'button');
c.setAttribute('aria-expanded', 'false');
c.addEventListener('keydown', function (e) {
if ((e.key === 'Enter' || e.key === ' ') && c !== open) { e.preventDefault(); stop(); expand(c); }
});
});
close = document.createElement('button');
close.type = 'button';
close.className = 'ecc-close';
close.setAttribute('aria-label', 'Close');
close.innerHTML = CROSS;
close.hidden = true;
el.appendChild(close);
close.addEventListener('click', function () { stop(); collapse(); });
el.addEventListener('keydown', function (e) { if (e.key === 'Escape' && open) { stop(); collapse(); } });
if (d.controls !== 'false') {
var nav = document.createElement('div');
nav.className = 'ecc-nav';
nav.innerHTML = '<button type="button" aria-label="Previous">' + CHEVRON + '</button><button type="button" aria-label="Next">' + CHEVRON + '</button>';
el.appendChild(nav);
nav.children[0].addEventListener('click', function () { stop(); move(-1); });
nav.children[1].addEventListener('click', function () { stop(); move(1); });
}
}
// Spacers either side let the first and last cards reach the middle.
function pad() {
var w = cards[0].offsetWidth;
var s = Math.max(0, (track.clientWidth - w) / 2) + 'px';
lead.style.width = tail.style.width = s;
shade();
}
// Cards away from the middle shrink and fade a little.
function shade() {
var mid = track.scrollLeft + track.clientWidth / 2;
cards.forEach(function (c) {
if (c === open) return;
var w = c.offsetWidth || 1;
var t = Math.min(1, Math.abs(c.offsetLeft + w / 2 - mid) / (w * 1.2));
c.style.transform = 'scale(' + (1 - t * 0.1).toFixed(3) + ')';
c.style.opacity = (1 - t * 0.35).toFixed(3);
});
}
function centred() {
var mid = track.scrollLeft + track.clientWidth / 2, best = 0, bestD = Infinity;
cards.forEach(function (c, i) {
var dd = Math.abs(c.offsetLeft + c.offsetWidth / 2 - mid);
if (dd < bestD) { bestD = dd; best = i; }
});
return best;
}
function scrollTo(i) {
var c = cards[Math.max(0, Math.min(cards.length - 1, i))];
track.scrollTo({ left: c.offsetLeft + c.offsetWidth / 2 - track.clientWidth / 2, behavior: reduced() ? 'auto' : 'smooth' });
}
function move(dir) { if (!open) scrollTo(centred() + dir); }
function box(node) {
var a = node.getBoundingClientRect(), b = el.getBoundingClientRect();
return { left: a.left - b.left, top: a.top - b.top, width: a.width, height: a.height };
}
// Lift the card out of the row and grow it to fill the carousel.
function expand(card) {
if (open || busy) return;
busy = true;
card.style.transform = 'none';
var from = box(card);
var radius = getComputedStyle(card).borderRadius;
placeholder = document.createElement('span');
placeholder.className = 'ecc-spacer';
placeholder.style.width = card.offsetWidth + 'px';
placeholder.style.flex = 'none';
track.insertBefore(placeholder, card);
el.appendChild(card);
open = card;
card.classList.add('ecc-open');
card._eccRadius = radius;
card.style.opacity = '1';
anime.utils.set(card, { left: from.left, top: from.top, width: from.width, height: from.height });
var details = card.querySelectorAll('[data-detail]');
anime.utils.set(details, { opacity: 0, y: 12 });
if (!inThumb) {
card.setAttribute('aria-expanded', 'true');
returnFocus = document.activeElement === card ? card : null;
close.hidden = false;
}
var done = function () {
busy = false;
if (close && returnFocus) close.focus();
};
if (reduced()) {
anime.utils.set(card, { left: 0, top: 0, width: el.clientWidth, height: el.clientHeight, borderRadius: 0 });
anime.utils.set(details, { opacity: 1, y: 0 });
return done();
}
anime.animate(card, { left: 0, top: 0, width: el.clientWidth, height: el.clientHeight, borderRadius: 0, duration: duration, ease: 'inOut(3)', onComplete: done });
if (close) anime.animate(close, { opacity: [0, 1], scale: [0.6, 1], duration: 300, delay: duration * 0.6, ease: 'out(3)' });
anime.animate(details, { opacity: 1, y: 0, duration: 450, delay: anime.stagger(70, { start: duration * 0.55 }), ease: 'out(3)' });
}
// Shrink the open card back into its gap in the row.
function collapse() {
if (!open || busy) return;
busy = true;
var card = open;
var to = box(placeholder);
var details = card.querySelectorAll('[data-detail]');
var done = function () {
card.classList.remove('ecc-open');
['left', 'top', 'width', 'height', 'borderRadius'].forEach(function (p) { card.style[p] = ''; });
track.replaceChild(card, placeholder);
placeholder = null;
open = null;
busy = false;
anime.utils.set(details, { opacity: 1, y: 0 });
shade();
if (close) {
close.hidden = true;
card.setAttribute('aria-expanded', 'false');
if (returnFocus || el.contains(document.activeElement) && document.activeElement === close) card.focus();
}
};
if (reduced()) return done();
anime.animate(details, { opacity: 0, duration: 160, ease: 'out(2)' });
anime.animate(card, { left: to.left, top: to.top, width: to.width, height: to.height, borderRadius: card._eccRadius, duration: duration * 0.9, ease: 'inOut(3)', onComplete: done });
}
el._ecc = function (i) { stop(); if (i < 0) collapse(); else if (cards[i]) { scrollTo(i); expand(cards[i]); } };
track.addEventListener('scroll', function () { if (!busy) shade(); }, { passive: true });
track.addEventListener('click', function (e) {
if (track._eccDragged) { track._eccDragged = false; return; }
var card = cards.find(function (c) { return c.contains(e.target); });
if (!card || inThumb) return;
stop();
// A card at the side slides to the middle first; the middle one opens.
if (cards.indexOf(card) !== centred()) scrollTo(cards.indexOf(card));
else expand(card);
});
// Mouse drag scrolls the row (touch and trackpads scroll it natively).
if (!inThumb) {
var down = null;
track.addEventListener('pointerdown', function (e) {
if (e.pointerType !== 'mouse' || e.button > 0) return;
down = { x: e.clientX, left: track.scrollLeft, moved: false };
});
window.addEventListener('pointermove', function (e) {
if (!down) return;
var dx = e.clientX - down.x;
if (!down.moved && Math.abs(dx) < 5) return;
if (!down.moved) { down.moved = true; stop(); track.classList.add('ecc-drag'); }
track.scrollLeft = down.left - dx;
});
window.addEventListener('pointerup', function () {
if (!down) return;
if (down.moved) {
track._eccDragged = true;
var left = track.scrollLeft;
track.classList.remove('ecc-drag');
track.scrollLeft = left;
scrollTo(centred());
}
down = null;
});
track.addEventListener('touchstart', stop, { passive: true });
track.addEventListener('wheel', stop, { passive: true });
}
pad();
track.scrollLeft = 0;
if (window.ResizeObserver) new ResizeObserver(function () {
pad();
if (open && !busy) anime.utils.set(open, { width: el.clientWidth, height: el.clientHeight });
}).observe(el);
function stop() { stopped = true; }
// Autoplay: slide along twice, open the middle card, close it, repeat.
if (d.autoplay !== undefined && d.autoplay !== 'false' && !reduced()) {
var every = +d.autoplay || 1800, beat = 0, dir = 1, timer = null;
new IntersectionObserver(function (entries) {
clearInterval(timer);
timer = null;
if (!entries[0].isIntersecting || stopped) return;
timer = setInterval(function () {
if (stopped) { clearInterval(timer); return; }
if (busy || el.closest('[hidden]')) return;
beat++;
if (open) return collapse();
if (beat % 3 === 0) return expand(cards[centred()]);
var i = centred();
if (i + dir < 0 || i + dir > cards.length - 1) dir = -dir;
scrollTo(i + dir);
}, every);
}).observe(el);
}
}
function init() {
document.querySelectorAll('[data-expanding-card-carousel]').forEach(function (el) {
if (el._ecc) return;
el._ecc = true;
setup(el);
});
}
window.animacious = window.animacious || {};
window.animacious.expandingCard = function (el, i) { if (el && typeof el._ecc === 'function') el._ecc(i); };
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-expanding-card-carousel aria-label="Places to go" style="height: 420px">
<article>
<img src="lake.jpg" alt="">
<h3>Alpine Lake</h3>
<p data-detail>Clear water and snow on the peaks.</p>
</article>
<article>…</article>
<article>…</article>
</div>