Coverflow Carousel
The current item faces you in the middle while the rest angle away in 3D on either side, like flicking through a box of records. Use the buttons, the arrow keys, drag or swipe, or click an item at the side.
How to use it
- Paste the snippet just before
</head>. - Add
data-coverflow-carouseland anaria-labelto a container whose children are the items, and give the items a width.
Options
Add any of these attributes to the same element.
data-start | Index of the item shown first, from 0. Default the middle one. |
data-angle | How far the side items turn, in degrees. Default 50. |
data-autoplay | Milliseconds between moves. It steps along and back, pauses under the pointer and stops once someone uses it. |
data-controls="false" | No previous and next buttons. |
It’s announced as a carousel; the item in the middle is the one screen readers and the Tab key see, and moving is announced politely. From code, animacious.coverflow(element, 2) moves to an item.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Coverflow Carousel: the current item faces you in the middle while the
// others angle away in 3D on either side, like flicking through records.
// Move with the arrow buttons, the arrow keys, by dragging or swiping, or
// by clicking an item at the side.
// Usage:
// <div data-coverflow-carousel aria-label="Our work">
// <figure><img src="one.jpg" alt="…"><figcaption>One</figcaption></figure>
// <figure>…</figure>
// </div>
// Each child is an item; give them a width. Optional: data-start (index of
// the first item shown, default the middle one), data-angle (how far side
// items turn, in degrees, default 50), data-autoplay (ms between moves;
// it goes there and back and stops when someone uses it),
// data-controls="false" (no buttons).
// Script use: animacious.coverflow(element, index) moves it to an item.
var DEFAULTS = { angle: 50, duration: 650, visible: 3 };
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 css =
'[data-coverflow-carousel]{display:grid;justify-items:center;align-items:center;perspective:1100px;touch-action:pan-y;user-select:none;-webkit-user-select:none}' +
'[data-coverflow-carousel]>*{grid-area:1/1}' +
'[data-coverflow-carousel]>:not(.cvf-nav){cursor:pointer}' +
'[data-coverflow-carousel]>.cvf-current{cursor:default}' +
'[data-coverflow-carousel]:focus-visible{outline:none}' +
'[data-coverflow-carousel]:focus-visible>.cvf-current{outline:3px solid #a855f7;outline-offset:4px}' +
'.cvf-nav{grid-area:2/1!important;display:flex;align-items:center;gap:14px;margin-top:22px;font:600 14px/1 system-ui,sans-serif;color:inherit}' +
'.cvf-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}' +
'.cvf-nav button:last-of-type svg{transform:scaleX(-1)}' +
'.cvf-nav button:disabled{opacity:.4;cursor:default}' +
'.cvf-nav button:focus-visible{outline:3px solid #a855f7;outline-offset:2px}' +
'.cvf-count{min-width:3.5em;text-align:center;font-variant-numeric:tabular-nums}' +
'.cvf-sr{position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function setup(el) {
var d = el.dataset;
var items = Array.from(el.children);
var n = items.length;
if (n < 2) return;
var reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var angle = d.angle !== undefined ? +d.angle : DEFAULTS.angle;
var inThumb = !!el.closest('[aria-hidden="true"]');
var current = d.start !== undefined ? Math.max(0, Math.min(n - 1, +d.start)) : Math.floor((n - 1) / 2);
var dir = 1, timer = null, stopped = false, paused = false;
if (!inThumb) {
el.setAttribute('role', 'region');
el.setAttribute('aria-roledescription', 'carousel');
if (!el.hasAttribute('tabindex')) el.tabIndex = 0;
}
items.forEach(function (item, i) {
item.setAttribute('role', 'group');
item.setAttribute('aria-roledescription', 'slide');
if (!item.getAttribute('aria-label')) item.setAttribute('aria-label', i + 1 + ' of ' + n);
anime.utils.set(item, { filter: 'brightness(1)' });
});
var prev, next, count, live;
if (d.controls !== 'false' && !inThumb) {
var nav = document.createElement('div');
nav.className = 'cvf-nav';
nav.innerHTML =
'<button type="button" aria-label="Previous">' + CHEVRON + '</button>' +
'<span class="cvf-count" aria-hidden="true"></span>' +
'<button type="button" aria-label="Next">' + CHEVRON + '</button>' +
'<span class="cvf-sr" aria-live="polite"></span>';
el.appendChild(nav);
prev = nav.children[0];
count = nav.children[1];
next = nav.children[2];
live = nav.children[3];
prev.addEventListener('click', function () { stop(); go(current - 1); });
next.addEventListener('click', function () { stop(); go(current + 1); });
}
// Where an item sits, from its distance to the centre (can be fractional while dragging).
function pose(o, w) {
var a = Math.abs(o), s = o < 0 ? -1 : 1, t = Math.min(a, 1), far = Math.max(0, a - 1);
return {
x: s * (t * w * 0.62 + far * w * 0.34),
rotateY: -s * t * angle,
translateZ: -(t * w * 0.4 + far * w * 0.14),
filter: 'brightness(' + (1 - Math.min(a, DEFAULTS.visible) * 0.14).toFixed(3) + ')',
opacity: Math.max(0, Math.min(1, DEFAULTS.visible + 1 - a))
};
}
function render(pos, animate) {
var w = items[Math.round(pos)].offsetWidth || 200;
items.forEach(function (item, i) {
var o = i - pos, props = pose(o, w);
item.style.zIndex = 100 - Math.round(Math.abs(o) * 10);
if (!animate || reduced) return anime.utils.set(item, props);
props.duration = DEFAULTS.duration;
props.ease = 'out(3)';
anime.animate(item, props);
});
}
function update() {
items.forEach(function (item, i) {
var on = i === current;
item.classList.toggle('cvf-current', on);
item.setAttribute('aria-hidden', on ? 'false' : 'true');
// Only the current item's links and buttons are in the tab order.
item.querySelectorAll('a,button,input,select,textarea,[tabindex]').forEach(function (f) {
if (on) { if (f.dataset.cvfTab !== undefined) { f.setAttribute('tabindex', f.dataset.cvfTab); delete f.dataset.cvfTab; } }
else if (f.dataset.cvfTab === undefined) { f.dataset.cvfTab = f.getAttribute('tabindex') || '0'; f.setAttribute('tabindex', '-1'); }
});
});
if (prev) {
prev.disabled = current === 0;
next.disabled = current === n - 1;
count.textContent = current + 1 + ' / ' + n;
}
}
function go(i, quiet) {
i = Math.max(0, Math.min(n - 1, i));
if (i === current) return render(current, true);
current = i;
update();
render(current, true);
if (live && !quiet) {
var cap = items[i].querySelector('figcaption,h2,h3,[alt]');
live.textContent = 'Item ' + (i + 1) + ' of ' + n + (cap ? ': ' + (cap.getAttribute('alt') || cap.textContent.trim()) : '');
}
}
el._cvf = function (i) { stop(); go(i); };
function stop() { stopped = true; clearInterval(timer); timer = null; }
update();
render(current, false);
if (window.ResizeObserver) new ResizeObserver(function () { render(current, false); }).observe(el);
el.addEventListener('keydown', function (e) {
if (e.target !== el) return;
var to = e.key === 'ArrowLeft' ? current - 1 : e.key === 'ArrowRight' ? current + 1 : e.key === 'Home' ? 0 : e.key === 'End' ? n - 1 : null;
if (to === null) return;
e.preventDefault();
stop();
go(to);
});
// Drag or swipe: the items follow the pointer, then settle on the nearest.
// A click on an item at the side brings it to the middle. (Not in a
// decorative copy, e.g. inside a link: clicks there are the link's.)
if (!inThumb) {
var down = null, dragged = false;
el.addEventListener('pointerdown', function (e) {
if (e.target.closest('.cvf-nav') || e.button > 0) return;
down = { x: e.clientX, from: current, id: e.pointerId };
dragged = false;
});
el.addEventListener('pointermove', function (e) {
if (!down) return;
var dx = e.clientX - down.x;
if (!dragged && Math.abs(dx) < 6) return;
if (!dragged) { dragged = true; stop(); el.setPointerCapture(down.id); }
var w = items[current].offsetWidth || 200;
render(Math.max(-0.4, Math.min(n - 0.6, down.from - dx / (w * 0.62))), false);
});
var release = function (e) {
if (!down) return;
if (dragged && e.type === 'pointercancel') go(current);
else if (dragged) go(Math.round(down.from - (e.clientX - down.x) / ((items[current].offsetWidth || 200) * 0.62)));
down = null;
};
el.addEventListener('pointerup', release);
el.addEventListener('pointercancel', release);
el.addEventListener('click', function (e) {
if (dragged) { dragged = false; e.preventDefault(); return; }
var item = items.find(function (it) { return it.contains(e.target); });
if (!item || item.classList.contains('cvf-current')) return;
e.preventDefault();
stop();
go(items.indexOf(item));
}, true);
}
// Autoplay: step along and back again, paused under the pointer or focus.
if (d.autoplay !== undefined && d.autoplay !== 'false' && !reduced) {
var every = +d.autoplay || 2200;
el.addEventListener('pointerenter', function (e) { if (e.pointerType !== 'touch') paused = true; });
el.addEventListener('pointerleave', function () { paused = false; });
el.addEventListener('focusin', function () { paused = true; });
el.addEventListener('focusout', function () { paused = false; });
new IntersectionObserver(function (entries) {
clearInterval(timer);
timer = null;
if (!entries[0].isIntersecting || stopped) return;
timer = setInterval(function () {
if (paused || el.closest('[hidden]')) return;
if (current + dir < 0 || current + dir > n - 1) dir = -dir;
go(current + dir, true);
}, every);
}).observe(el);
}
}
function init() {
document.querySelectorAll('[data-coverflow-carousel]').forEach(function (el) {
if (el._cvf) return;
setup(el);
el._cvf = el._cvf || true;
});
}
window.animacious = window.animacious || {};
window.animacious.coverflow = function (el, i) { if (el && typeof el._cvf === 'function') el._cvf(i); };
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-coverflow-carousel aria-label="Our work">
<figure><img src="one.jpg" alt="…"><figcaption>One</figcaption></figure>
<figure><img src="two.jpg" alt="…"><figcaption>Two</figcaption></figure>
<figure><img src="three.jpg" alt="…"><figcaption>Three</figcaption></figure>
</div>