Coding soon

Coding soon

Coverflow

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
	}
);