№ 005
Fling Stack
A swipeable card stack — fling left or right, and flung cards loop to the back.
- Version
- 1.0.0
- Download
- 7 KB · 3 files
- Added
- Oct 26, 2026
- Built with
- HTML, CSS, JS
- Needs
- Google Fonts
- Tags
- swipe, cards, gesture
Usage
Copy fling-stack.css and fling-stack.js (and assets/, if there is one) into your project, then:
const stack = new FlingStack(element, {
cards: [{ tag, title, subtitle, color, color2 }],
onChange(card, index) {}, // the card now on top
onSwipe(card, 'left' | 'right') {} // a card was flung away
});
stack.swipe('left' | 'right'); // fling the top card programmatically
Cards loop: a flung card goes to the back of the stack.Source
/* Fling — a swipeable card stack. Drag the top card away; the next one springs forward. */
.fs {
position: relative;
width: min(320px, 100%);
aspect-ratio: 320 / 440;
outline: none;
user-select: none;
-webkit-user-select: none;
-webkit-tap-highlight-color: transparent;
}
.fs:focus-visible { outline: 2px solid var(--fs-focus, #17141f); outline-offset: 14px; border-radius: 36px; }
.fs-card {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
justify-content: space-between;
padding: 22px;
overflow: hidden;
border-radius: 28px;
color: #fff;
text-align: left;
background: linear-gradient(155deg, var(--c1), var(--c2));
box-shadow:
0 26px 40px -18px rgba(30, 22, 48, 0.5),
inset 0 1px 0 rgba(255, 255, 255, 0.28);
transform-origin: 50% 115%;
touch-action: none;
cursor: grab;
will-change: transform;
transition: transform 0.55s cubic-bezier(0.3, 1.35, 0.5, 1), opacity 0.3s, filter 0.3s;
}
.fs-card.is-dragging { transition: filter 0.3s; cursor: grabbing; }
.fs-card.is-gone { transition: transform 0.45s cubic-bezier(0.2, 0.7, 0.3, 1), opacity 0.45s ease-in; pointer-events: none; }
.fs-card.is-reset { transition: none; }
/* soft light blob in the corner */
.fs-card::before {
content: "";
position: absolute;
top: -70px;
right: -70px;
width: 240px;
height: 240px;
border-radius: 50%;
background: radial-gradient(circle at 35% 35%, rgba(255, 255, 255, 0.55), rgba(255, 255, 255, 0) 65%);
pointer-events: none;
}
.fs-top, .fs-bottom { position: relative; }
.fs-tag {
display: inline-block;
padding: 6px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.2);
backdrop-filter: blur(6px);
font-size: 12px;
font-weight: 600;
letter-spacing: 0.04em;
}
.fs-title {
margin: 0;
font-size: 44px;
font-weight: 800;
letter-spacing: -0.045em;
line-height: 1;
}
.fs-sub { margin: 10px 0 0; font-size: 16px; line-height: 1.35; opacity: 0.88; }
.fs-count { margin-top: 18px; font-size: 12px; font-weight: 500; letter-spacing: 0.12em; opacity: 0.7; }
.fs-stamp {
position: absolute;
top: 64px;
padding: 4px 12px;
border: 3px solid;
border-radius: 10px;
font-size: 24px;
font-weight: 800;
letter-spacing: 0.08em;
opacity: 0;
pointer-events: none;
}
.fs-stamp.keep { left: 22px; transform: rotate(-14deg); color: #d4ff8a; }
.fs-stamp.skip { right: 22px; transform: rotate(14deg); color: #ffd3dc; }
.fs-live {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
}
@media (prefers-reduced-motion: reduce) {
.fs-card { transition: opacity 0.2s, filter 0.2s; }
.fs-card.is-gone { transition: opacity 0.2s; }
} /* Fling — usage:
* const stack = new FlingStack(element, {
* cards: [{ tag, title, subtitle, color, color2 }],
* onChange(card, index) {}, // the card now on top
* onSwipe(card, 'left' | 'right') {} // a card was flung away
* });
* stack.swipe('left' | 'right'); // fling the top card programmatically
* Cards loop: a flung card goes to the back of the stack.
*/
(function () {
const STEP_Y = 16; // vertical offset per depth level
const STEP_SCALE = 0.055; // scale lost per depth level
const MAX_VISIBLE = 3; // cards visible behind the top one
const FLY_MS = 450;
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
class FlingStack {
constructor(root, { cards, onChange, onSwipe, threshold = 110 } = {}) {
this.root = root;
this.cards = cards;
this.onChange = onChange;
this.onSwipe = onSwipe;
this.threshold = threshold;
this.top = 0;
this.busy = false;
this.flying = null;
this.drag = null;
this.build();
this.layout();
if (this.onChange) this.onChange(this.cards[0], 0);
}
build() {
const n = this.cards.length;
this.root.classList.add('fs');
this.root.tabIndex = 0;
this.root.setAttribute('role', 'group');
this.root.setAttribute('aria-roledescription', 'card stack');
this.root.setAttribute('aria-label', 'Card stack. Use left and right arrow keys to skip or keep.');
this.els = this.cards.map((c, i) => {
const el = document.createElement('div');
el.className = 'fs-card';
el.style.setProperty('--c1', c.color);
el.style.setProperty('--c2', c.color2 || c.color);
el.innerHTML = `
<div class="fs-top"><span class="fs-tag">${esc(c.tag || '')}</span></div>
<div class="fs-bottom">
<h2 class="fs-title">${esc(c.title)}</h2>
<p class="fs-sub">${esc(c.subtitle || '')}</p>
<div class="fs-count">${String(i + 1).padStart(2, '0')} / ${String(n).padStart(2, '0')}</div>
</div>
<span class="fs-stamp keep">KEEP</span>
<span class="fs-stamp skip">SKIP</span>`;
el.keep = el.querySelector('.keep');
el.skip = el.querySelector('.skip');
this.root.appendChild(el);
return el;
});
this.live = document.createElement('div');
this.live.className = 'fs-live';
this.live.setAttribute('aria-live', 'polite');
this.root.appendChild(this.live);
this.root.addEventListener('pointerdown', (e) => this.dragStart(e));
this.root.addEventListener('pointermove', (e) => this.dragMove(e));
this.root.addEventListener('pointerup', (e) => this.dragEnd(e));
this.root.addEventListener('pointercancel', (e) => this.dragEnd(e));
this.root.addEventListener('keydown', (e) => {
if (e.key === 'ArrowRight') { e.preventDefault(); this.swipe('right'); }
if (e.key === 'ArrowLeft') { e.preventDefault(); this.swipe('left'); }
});
}
get topEl() { return this.els[this.top]; }
// Position every card behind the top one. `p` (0..1) pulls them forward as the top card is dragged away.
layout(p = 0) {
const n = this.els.length;
const cap = Math.min(MAX_VISIBLE, n - 1);
this.els.forEach((el, i) => {
if (el === this.flying) return;
const d = (i - this.top + n) % n;
el.style.zIndex = n - d;
if (d === 0) {
if (!this.drag) {
el.style.transform = '';
el.style.opacity = 1;
el.style.filter = '';
}
return;
}
const e = d - p;
el.style.transform = `translateY(${e * STEP_Y}px) scale(${1 - e * STEP_SCALE})`;
el.style.opacity = e <= cap ? 1 : clamp(cap + 1 - e, 0, 1);
el.style.filter = `brightness(${1 - e * 0.05})`;
});
}
dragStart(e) {
if (this.busy || !this.topEl.contains(e.target)) return;
this.drag = { x: e.clientX, y: e.clientY, lastX: e.clientX, lastT: e.timeStamp, vx: 0, dx: 0 };
this.topEl.classList.add('is-dragging');
try { this.root.setPointerCapture(e.pointerId); } catch (_) { /* synthetic pointers can't be captured */ }
}
dragMove(e) {
const d = this.drag;
if (!d) return;
d.dx = e.clientX - d.x;
const dy = clamp((e.clientY - d.y) * 0.35, -60, 60);
const dt = Math.max(e.timeStamp - d.lastT, 1);
d.vx = d.vx * 0.6 + ((e.clientX - d.lastX) / dt) * 0.4; // px per ms
d.lastX = e.clientX;
d.lastT = e.timeStamp;
const el = this.topEl;
el.style.transform = `translate(${d.dx}px, ${dy}px) rotate(${d.dx / 18}deg)`;
el.keep.style.opacity = clamp(d.dx / (this.threshold * 0.8), 0, 1);
el.skip.style.opacity = clamp(-d.dx / (this.threshold * 0.8), 0, 1);
d.dy = dy;
this.layout(clamp(Math.abs(d.dx) / this.threshold, 0, 1));
}
dragEnd(e) {
const d = this.drag;
if (!d) return;
this.drag = null;
if (this.root.hasPointerCapture && this.root.hasPointerCapture(e.pointerId)) {
this.root.releasePointerCapture(e.pointerId);
}
const el = this.topEl;
el.classList.remove('is-dragging');
const flick = Math.abs(d.vx) > 0.7 && Math.abs(d.dx) > 24;
if (Math.abs(d.dx) > this.threshold || flick) {
this.fling(d.dx >= 0 ? 1 : -1, d.dx, d.dy || 0);
} else {
el.keep.style.opacity = 0;
el.skip.style.opacity = 0;
this.layout(0); // springs back via the card's transition
}
}
swipe(dir) {
if (this.busy || this.drag) return;
this.fling(dir === 'left' ? -1 : 1, 0, 0);
}
fling(dir, dx, dy) {
const el = this.topEl;
const card = this.cards[this.top];
this.busy = true;
this.flying = el;
el.classList.add('is-gone');
el.style.zIndex = this.els.length + 1;
el.style.transform = `translate(${dir * 900}px, ${dy + 60}px) rotate(${dir * 30}deg)`;
el.style.opacity = 0;
el.style.filter = '';
el.keep.style.opacity = dir > 0 ? 1 : 0;
el.skip.style.opacity = dir < 0 ? 1 : 0;
this.top = (this.top + 1) % this.els.length;
this.layout(0); // the rest of the stack springs forward
this.live.textContent = `${card.title}: ${dir > 0 ? 'kept' : 'skipped'}. Next: ${this.cards[this.top].title}.`;
if (this.onSwipe) this.onSwipe(card, dir > 0 ? 'right' : 'left');
if (this.onChange) this.onChange(this.cards[this.top], this.top);
setTimeout(() => this.respawn(el), reducedMotion.matches ? 200 : FLY_MS);
}
// Send the flung card to the back of the stack without it visibly travelling there.
respawn(el) {
el.classList.remove('is-gone');
el.classList.add('is-reset');
el.style.opacity = 0;
el.keep.style.opacity = 0;
el.skip.style.opacity = 0;
this.flying = null;
this.layout(0);
el.style.opacity = 0;
void el.offsetWidth; // commit the hidden position before animating in
el.classList.remove('is-reset');
this.layout(0);
this.busy = false;
}
}
window.FlingStack = FlingStack;
})(); <!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Fling Stack</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Schibsted+Grotesk:wght@400;500;600;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="fling-stack.css">
<style>
* { box-sizing: border-box; }
html, body { min-height: 100%; margin: 0; }
body {
--accent: #7c5cff;
display: grid;
place-items: center;
min-height: 100vh;
padding: 24px 16px;
overflow-x: hidden;
background:
radial-gradient(ellipse 90% 50% at 50% -5%, #ffffff 0%, rgba(255, 255, 255, 0) 70%),
linear-gradient(#f6f4f9, #e9e6ef);
color: #17141f;
font-family: "Schibsted Grotesk", system-ui, sans-serif;
}
.stage {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
text-align: center;
}
.glow {
position: absolute;
top: 52%;
left: 50%;
width: 520px;
height: 420px;
transform: translate(-50%, -30%);
color: var(--accent);
background: radial-gradient(closest-side, currentColor, transparent);
opacity: 0.4;
filter: blur(26px);
pointer-events: none;
transition: color 0.6s;
}
.eyebrow {
display: flex;
align-items: center;
gap: 8px;
color: #6b6478;
font-size: 12px;
font-weight: 500;
letter-spacing: 0.18em;
}
.eyebrow::before {
content: "";
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--accent);
box-shadow: 0 0 0 3px rgba(23, 20, 31, 0.07), 0 0 12px var(--accent);
transition: background-color 0.5s, box-shadow 0.5s;
}
h1 {
margin: 12px 0 0;
font-size: clamp(44px, 12vw, 72px);
font-weight: 800;
letter-spacing: -0.045em;
line-height: 1;
}
.sub { margin: 10px 0 0; color: #5c566a; font-size: 15px; }
#stack { margin-top: 36px; }
/* the cards behind the top one peek out ~50px below it */
.actions { display: flex; gap: 22px; margin-top: 78px; }
.btn {
display: grid;
place-items: center;
width: 60px;
height: 60px;
padding: 0;
border: 0;
border-radius: 50%;
background: #fff;
box-shadow: 0 10px 24px -8px rgba(30, 22, 48, 0.35), inset 0 0 0 1px rgba(23, 20, 31, 0.06);
cursor: pointer;
transition: transform 0.2s cubic-bezier(0.3, 1.5, 0.5, 1);
}
.btn:hover { transform: translateY(-2px) scale(1.06); }
.btn:active { transform: scale(0.94); }
.btn:focus-visible { outline: 2px solid #17141f; outline-offset: 3px; }
.btn svg { width: 26px; height: 26px; }
.btn.skip { color: #e0375f; }
.btn.keep { color: #2e9e5b; }
.hint { margin: 26px 0 0; color: #7a7388; font-size: 13px; }
.hint u { color: #2a2536; text-decoration-color: rgba(23, 20, 31, 0.35); text-underline-offset: 3px; }
</style>
</head>
<body>
<main class="stage">
<div class="glow" aria-hidden="true"></div>
<div class="eyebrow">FLING</div>
<h1>Swipe.</h1>
<p class="sub">Keep what you love. Skip the rest.</p>
<div id="stack"></div>
<div class="actions">
<button class="btn skip" id="skip" aria-label="Skip">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"><path d="M6 6l12 12M18 6 6 18"/></svg>
</button>
<button class="btn keep" id="keep" aria-label="Keep">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12.5l4.5 4.5L19 7.5"/></svg>
</button>
</div>
<p class="hint"><u>Drag a card</u> away — or use the buttons or arrow keys.</p>
</main>
<script src="fling-stack.js"></script>
<script>
const stack = new FlingStack(document.getElementById('stack'), {
cards: [
{ tag: 'WEATHER', title: 'Aurora', subtitle: 'Weather, but poetic.', color: '#7c5cff', color2: '#3ac9ff' },
{ tag: 'HABITS', title: 'Ember', subtitle: 'A habit tracker that glows.', color: '#ff7a45', color2: '#ff3d77' },
{ tag: 'FOCUS', title: 'Tide', subtitle: 'A focus timer with waves.', color: '#19b6a6', color2: '#2e6bff' },
{ tag: 'PLANTS', title: 'Moss', subtitle: 'Plant care, gently.', color: '#8bc34a', color2: '#1f9d6b' },
{ tag: 'JOURNAL', title: 'Dusk', subtitle: 'A journal for late nights.', color: '#3b2b6b', color2: '#d65f9b' },
],
onChange(card) {
document.body.style.setProperty('--accent', card.color);
},
});
document.getElementById('skip').addEventListener('click', () => stack.swipe('left'));
document.getElementById('keep').addEventListener('click', () => stack.swipe('right'));
</script>
</body>
</html>