class Coverflow {
constructor(el, opts = {}) {
this.el = el;
this.o = {
horizontal: false,
width: 160,
height: 300,
itemSize: 116,
space: 0,
angle: 70,
shift: 35,
curve: 0,
alpha: 3,
loop: false,
keys: false,
click: false,
wheel: true,
...opts
};
this.items = [...el.children];
// State
this.off = 0;
this.tgt = 0;
this.vel = 0;
this.drag = null;
this.raf = null;
this.wAcc = 0;
// Cache Math
this.step = this.o.itemSize + this.o.space;
this.max = (this.items.length - 1) * this.step;
this.update = this.update.bind(this);
// Setup DOM
el.classList.add("coverflow");
el.style.width = this.o.width + "px";
el.style.height = this.o.height + "px";
this.items.forEach((c, i) => {
c.classList.add("cover");
c.style.width = c.style.height = this.o.itemSize + "px";
c.dataset.index = i;
});
// Events
Object.entries({
"pointerdown": this._down,
"pointermove": this._move,
"pointerup": this._up,
"pointerleave": this._up,
...(this.o.wheel ? {
"wheel": this._wheel
} : {}),
...(this.o.keys ? {
"keydown": this._key
} : {})
})
.forEach(([ev, cb]) =>
el.addEventListener(
ev,
cb.bind(this)
));
this.render();
}
// --- Math & Helpers ---
_limit(v) {
if(this.o.loop)
return v;
return Math.max(
-this.o.shift,
Math.min(
v,
this.max + this.o.shift
)
);
}
_idx() {
const n = this.items.length;
return ((Math.round(this.off / this.step) % n) + n) % n;
}
// --- Core Loop ---
update() {
let running = true;
if(this.drag) {
this.off = this._limit(this.drag.startOff - (this.drag.curr - this.drag.start));
}
else {
const diff = this.tgt - this.off;
if(Math.abs(diff) < 0.1) {
this.off = this.tgt;
running = false;
this.o.onEnd?.(this._idx());
}
else {
this.off += diff * 0.1;
}
}
this.render();
this.o.onScroll?.(this.off);
if(running || this.drag) {
this.raf = requestAnimationFrame(this.update);
}
else {
this.raf = null;
}
}
render() {
const {
items, off, o, step
} = this;
if(!items.length)
return;
const {
horizontal: hz, width, height, itemSize, angle, shift, alpha, loop, curve
} = o;
const size = hz ? width : height;
const cx = (width - itemSize) / 2;
const cy = (height - itemSize) / 2;
const total = items.length * step;
const half = total / 2;
// Hoist constants
const invSize = 1 / size;
const invStep2 = 2 / step;
const curveFactor = curve ? size / curve : 0;
const itemCurve = itemSize * curve;
for(let i = 0; i < items.length; i++) {
let rel = i * step - off;
if(loop)
rel = ((rel + half) % total + total) % total - half;
const absRel = Math.abs(rel);
const opacity = Math.max(
0,
1 - absRel * invSize * alpha
);
items[i].style.opacity = opacity;
if(opacity < 0.01)
continue;
const t = Math.max(
-1,
Math.min(
1,
rel * invStep2
)
);
let p = (curve ? rel / 2 : rel) + t * shift;
let z = 0;
let rot = (p * invSize) * angle;
if(curve) {
const theta = (p * invSize) * curve;
p = Math.sin(theta) * curveFactor;
z = -absRel * 0.5 + (Math.cos(theta) - 1) * itemCurve;
}
items[i].style.transform = hz
? `translate3d(${cx + p}px,${cy}px,${z}px)rotateY(${rot}deg)`
: `translate3d(${cx}px,${cy + p}px,${z}px)rotateX(${-rot}deg)`;
}
}
// --- Actions ---
setTarget(v) {
this.tgt = this._limit(Math.round(v / this.step) * this.step);
if(!this.raf)
this.update();
}
select(idx) {
let diff = idx - this._idx();
if(this.o.loop) {
const n = this.items.length;
if(diff > n / 2)
diff -= n;
if(diff < -n / 2)
diff += n;
}
this.o.onBegin?.();
this.setTarget(this.off + diff * this.step);
this.o.onSelect?.(
idx,
0
);
}
goTo(v) {
this.off = this.tgt = v;
this.render();
}
resize(w, h) {
const cur = this._idx();
this.o.width = w;
this.o.height = h;
this.el.style.cssText = `width:${w}px;height:${h}px`;
this.step = this.o.itemSize + this.o.space;
this.max = (this.items.length - 1) * this.step;
this.off = cur * this.step;
this.render();
}
// --- Inputs ---
_down(e) {
e.preventDefault();
const p = this.o.horizontal ? e.clientX : e.clientY;
this.drag = {
start: p, curr: p, startOff: this.off, t: Date.now(), v: 0
};
this.vel = 0;
this.o.onBegin?.();
if(!this.raf)
this.update();
}
_move(e) {
e.preventDefault();
if(!this.drag)
return;
const p = this.o.horizontal ? e.clientX : e.clientY;
const now = Date.now();
const dt = now - this.drag.t;
if(dt > 10) {
this.vel = (p - this.drag.curr) / dt;
this.drag.t = now;
this.drag.curr = p;
}
}
_up(e) {
e.preventDefault();
if(!this.drag)
return;
const p = this.o.horizontal ? e.clientX : e.clientY;
const isClick = this.o.click && Math.abs(p - this.drag.start) < 20 && (Date.now() - this.drag.t) < 200;
const idx = +(e.target.closest(".cover")?.dataset.index ?? -1);
this.drag = null;
if(isClick && idx > -1) {
if(idx === this._idx())
this.o.onClick?.(idx);
this.select(idx);
}
else {
this.setTarget(this.off - this.vel * 200);
this.o.onSelect?.(this._idx());
}
}
_wheel(e) {
e.preventDefault();
this.wAcc += e.deltaY;
if(Math.abs(this.wAcc) < 30)
return;
this.tgt += Math.sign(this.wAcc) * this.step;
this.setTarget(this.tgt);
this.wAcc = 0;
}
_key(e) {
if(this.drag)
return;
const k = e.which;
if(![32, 33, 34, 37, 38, 39, 40].includes(k))
return;
e.preventDefault();
e.stopPropagation();
const dir = (k === 34 || k === 39 || k === 40 || k === 32) ? 1 : -1;
this.setTarget(this.tgt + dir * this.step);
}
}
.coverflow {
perspective: 1000px;
transform-style: preserve-3d;
}
.coverflow > .cover {
position: absolute;
top: 0;
left: 0;
border: none;
outline: none;
-webkit-user-select: none;
user-select: none;
will-change: transform, opacity;
backface-visibility: hidden;
}
<div id="coverflow"></div>
body {
display: flex;
align-items: center;
justify-content: center;
}
.cover {
display: flex;
justify-content: center;
align-items: center;
font-family: monospace;
user-select: none;
font-size: 1.5rem;
}
const flow = document.getElementById("coverflow");
const cols = [
"#ff595e", "#ff924c", "#ffca3a", "#c5ca30", "#8ac926",
"#36949d", "#1982c4", "#4267ac", "#565aa0", "#6a4c93"
];
for(let i = 0; i < 15; i++) {
const cover = document.createElement("div");
cover.style.background = cols[i % 10];
cover.innerHTML = i;
flow.append(cover);
}
new Coverflow(
flow,
{
horizontal: true,
width: 360,
height: 120,
itemSize: 120,
angle: 0,
curve: 1.4,
shift: 30,
space: 0,
alpha: 0
}
);