Live preview needs JavaScript. The code below works without any dependencies.
Prompt for your AI assistant
Paste this into Claude, Cursor or ChatGPT. The settings update as you move the controls above.
Build "Infinite snap cards" for my site using the Enrichr Library element at https://enrichr.dev/elements/infinite-snap-cards.html.
Read the machine-readable spec and source at https://enrichr.dev/elements/infinite-snap-cards.json (markup, CSS, JavaScript, parameters).
Requirements: vanilla JavaScript and CSS, no new dependencies; keep the behaviour of the source unchanged; respect prefers-reduced-motion; work on touch screens; keep it accessible.
Settings:
- accent: #e6ff55
- count: 6
- spread: 0.75
- fade: 0.32
- sensitivity: 1
Verify: reduced motion shows no movement, it works at 375px wide, there are no console errors, and scrolling stays smooth.The same spec as JSON, for tools that read it directly: /elements/infinite-snap-cards.json.
Code
Mount it on an element that contains the markup. It returns an object with update(options) and destroy().
HTML
<div class="ef-isc">
<div class="ef-isc__wrap" tabindex="0" role="group" aria-roledescription="carousel" aria-label="Infinite card stack. Use the up and down arrow keys, the mouse wheel or drag to move.">
<div class="ef-isc__stage"></div>
<div class="ef-isc__live" aria-live="polite"></div>
</div>
</div>CSS
/* Enrichr Library · https://enrichr.dev */
.ef-isc { width: 100%; }
.ef-isc__wrap { position: relative; box-sizing: border-box; width: 100%; height: 24rem; overflow: hidden; border-radius: 1rem; font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; touch-action: none; user-select: none; -webkit-user-select: none; cursor: grab; outline: none; overscroll-behavior: contain; }
.ef-isc__wrap.is-drag { cursor: grabbing; }
.ef-isc__wrap:focus-visible { box-shadow: inset 0 0 0 2px var(--ef-acc, #e6ff55); }
.ef-isc__stage { position: absolute; inset: 0; }
.ef-isc__card { position: absolute; left: 50%; top: 50%; width: min(86%, 24rem); height: 8.5rem; box-sizing: border-box; border-radius: 0.9rem; overflow: hidden; color: #fbf8f1; transform: translate3d(-50%, -50%, 0); will-change: transform, opacity; box-shadow: 0 14px 24px -14px rgba(0, 0, 0, 0.7); }
.ef-isc__card.is-active { box-shadow: 0 0 0 2px var(--ef-acc, #e6ff55), 0 18px 30px -14px rgba(0, 0, 0, 0.75); }
.ef-isc__art { position: absolute; inset: 0; }
.ef-isc__art::after { content: ""; position: absolute; inset: 0; background: linear-gradient(90deg, rgba(0, 0, 0, 0.55), rgba(0, 0, 0, 0.05) 70%); }
.ef-isc__n { position: absolute; top: 0.8rem; right: 1rem; font-size: 0.7rem; font-weight: 700; letter-spacing: 0.14em; opacity: 0.85; }
.ef-isc__txt { position: absolute; left: 1.1rem; bottom: 1rem; right: 1.1rem; pointer-events: none; }
.ef-isc__t { display: block; font-size: 1.35rem; font-weight: 800; letter-spacing: -0.01em; line-height: 1.1; }
.ef-isc__s { display: block; margin-top: 0.25rem; font-size: 0.74rem; letter-spacing: 0.05em; opacity: 0.8; }
.ef-isc__live { position: absolute; width: 1px; height: 1px; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
@media (prefers-reduced-motion: reduce) { .ef-isc__card { will-change: auto; } }JavaScript
// Enrichr Library · https://enrichr.dev
// Infinite snap cards: a vertical stack that loops forever. Wheel (only while the pointer is over it), drag, touch and the arrow keys move it;
// when input stops it eases to the nearest card. Cards scale and fade by their distance from the centre. Reduced motion steps one card at a time with no easing.
// mount(root, { background, accent, count (3-8), spread (0.4-1.1), fade (0.1-0.6), sensitivity (0.3-2) })
function mount(root, o) {
var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
var wrap = root.querySelector(".ef-isc__wrap");
var stage = root.querySelector(".ef-isc__stage");
var live = root.querySelector(".ef-isc__live");
var opts = {}, cards = [], offs = [], raf = 0, last = 0;
var pos = 0, target = null, vel = 0, step = 100, idle = 0, drag = null, acc = 0, shown = -1;
var DATA = [["Tidal", "Field notes"], ["Ember", "Studio series"], ["Moss", "Quiet objects"], ["Dusk", "Night shift"], ["Arch", "Form study"], ["Grain", "Print run"], ["Haze", "Soft light"], ["Loop", "Motion kit"]];
function art(i, a) {
var c = "#fbf8f1", d = "#0c1e18", p = i % 6;
if (p === 0) return "radial-gradient(circle at 78% 35%, " + a + " 0 16%, transparent 17%), linear-gradient(160deg, #005236, " + d + ")";
if (p === 1) return "conic-gradient(from 210deg at 70% 60%, #005236, " + a + ", " + c + ", #005236)";
if (p === 2) return "repeating-linear-gradient(120deg, #005236 0 12px, " + d + " 12px 24px)";
if (p === 3) return "radial-gradient(circle at 74% 40%, " + a + " 0 14%, transparent 15%), radial-gradient(circle at 88% 66%, " + c + " 0 12%, transparent 13%), linear-gradient(200deg, #0a6b46, " + d + ")";
if (p === 4) return "linear-gradient(190deg, #005236, " + a + ")";
return "radial-gradient(circle, " + a + " 0 2.5px, transparent 3px) 0 0 / 16px 16px, linear-gradient(145deg, #005236, " + d + ")";
}
function wrapIdx(i) { var n = cards.length; return ((i % n) + n) % n; }
function render() {
var n = cards.length;
for (var i = 0; i < n; i++) {
var d = i - pos;
d -= n * Math.round(d / n);
var ad = Math.abs(d);
var sc = Math.max(0.5, 1 - ad * 0.13);
var op = Math.max(0, 1 - ad * opts.fade);
var el = cards[i];
el.style.transform = "translate3d(-50%, calc(-50% + " + (d * step).toFixed(2) + "px), 0) scale(" + sc.toFixed(3) + ")";
el.style.opacity = op.toFixed(3);
el.style.zIndex = String(100 - Math.round(ad * 10));
el.style.visibility = op <= 0.01 ? "hidden" : "visible";
}
var a = wrapIdx(Math.round(pos));
if (a !== shown) {
shown = a;
for (var j = 0; j < n; j++) cards[j].classList.toggle("is-active", j === a);
live.textContent = DATA[a % DATA.length][0] + ", " + (a + 1) + " of " + n;
}
}
function measure() {
var h = cards[0] ? cards[0].offsetHeight : 136;
step = Math.max(24, h * opts.spread);
render();
}
function run() { if (!raf) { last = performance.now(); raf = requestAnimationFrame(tick); } }
function tick(t) {
raf = 0;
var dt = Math.min(0.05, (t - last) / 1000); last = t;
if (target !== null && !drag) {
pos += (target - pos) * (1 - Math.exp(-dt * 11));
if (Math.abs(target - pos) < 0.0008) { pos = target; target = null; }
}
render();
if (target !== null || drag) run();
}
function release() {
clearTimeout(idle); idle = 0;
if (reduce) { pos = Math.round(pos); render(); return; }
target = Math.round(pos + Math.max(-3, Math.min(3, vel)) * 0.25);
vel = 0;
run();
}
function goTo(t) {
if (reduce) { pos = t; target = null; render(); return; }
target = t; run();
}
function onWheel(e) {
e.preventDefault();
var dy = e.deltaY * (e.deltaMode === 1 ? 32 : e.deltaMode === 2 ? 400 : 1);
if (reduce) {
acc += dy;
if (Math.abs(acc) >= 80) { pos = Math.round(pos) + (acc > 0 ? 1 : -1); acc = 0; render(); }
return;
}
var dp = dy / 260 * opts.sensitivity;
pos += dp; vel = dp * 8; target = null;
run();
clearTimeout(idle);
idle = setTimeout(release, 110);
}
function onDown(e) {
if (e.pointerType === "mouse" && e.button !== 0) return;
drag = { y: e.clientY, t: performance.now(), moved: 0, total: 0, id: e.pointerId };
try { wrap.setPointerCapture(e.pointerId); } catch (x) {}
wrap.classList.add("is-drag");
target = null; vel = 0;
}
function onMove(e) {
if (!drag || e.pointerId !== drag.id) return;
var dy = e.clientY - drag.y, now = performance.now(), dtm = Math.max(1, now - drag.t);
drag.y = e.clientY; drag.t = now; drag.moved += Math.abs(dy); drag.total += dy;
if (reduce) return;
var dp = -dy / step * opts.sensitivity;
pos += dp;
vel = vel * 0.6 + (dp / dtm * 1000) * 0.4;
run();
}
function onUp(e) {
if (!drag || e.pointerId !== drag.id) return;
var d = drag; drag = null;
wrap.classList.remove("is-drag");
try { wrap.releasePointerCapture(e.pointerId); } catch (x) {}
if (d.moved < 5 && e.type === "pointerup") {
var el = e.target && e.target.closest ? e.target.closest(".ef-isc__card") : null;
var i = el ? cards.indexOf(el) : -1;
if (i >= 0) { var dd = i - pos; dd -= cards.length * Math.round(dd / cards.length); goTo(Math.round(pos + dd)); return; }
}
if (reduce) {
if (Math.abs(d.total) > step / 3) pos = Math.round(pos) + (d.total < 0 ? 1 : -1);
render();
return;
}
release();
}
function onKey(e) {
var k = e.key, dir = 0;
if (k === "ArrowDown" || k === "ArrowRight") dir = 1;
else if (k === "ArrowUp" || k === "ArrowLeft") dir = -1;
else return;
e.preventDefault();
goTo(Math.round(target !== null ? target : pos) + dir);
}
function clear() {
offs.forEach(function (f) { f(); }); offs = [];
if (raf) cancelAnimationFrame(raf);
raf = 0;
clearTimeout(idle); idle = 0;
}
function build() {
stage.textContent = "";
cards = [];
for (var i = 0; i < opts.count; i++) {
var c = document.createElement("div"); c.className = "ef-isc__card";
var bg = document.createElement("span"); bg.className = "ef-isc__art"; bg.style.background = art(i, opts.accent);
var n = document.createElement("span"); n.className = "ef-isc__n"; n.textContent = String(i + 1).padStart(2, "0");
var tx = document.createElement("span"); tx.className = "ef-isc__txt";
var t = document.createElement("span"); t.className = "ef-isc__t"; t.textContent = DATA[i % DATA.length][0];
var s = document.createElement("span"); s.className = "ef-isc__s"; s.textContent = DATA[i % DATA.length][1];
tx.appendChild(t); tx.appendChild(s);
c.appendChild(bg); c.appendChild(n); c.appendChild(tx);
stage.appendChild(c); cards.push(c);
}
shown = -1;
pos = wrapIdx(Math.round(pos)); target = null;
}
function bind() {
wrap.addEventListener("wheel", onWheel, { passive: false });
wrap.addEventListener("pointerdown", onDown);
wrap.addEventListener("pointermove", onMove);
wrap.addEventListener("pointerup", onUp);
wrap.addEventListener("pointercancel", onUp);
wrap.addEventListener("keydown", onKey);
var ro = null, onR = function () { measure(); };
if (window.ResizeObserver) { ro = new ResizeObserver(onR); ro.observe(wrap); } else window.addEventListener("resize", onR);
offs.push(function () {
wrap.removeEventListener("wheel", onWheel); wrap.removeEventListener("pointerdown", onDown);
wrap.removeEventListener("pointermove", onMove); wrap.removeEventListener("pointerup", onUp);
wrap.removeEventListener("pointercancel", onUp); wrap.removeEventListener("keydown", onKey);
if (ro) ro.disconnect(); else window.removeEventListener("resize", onR);
});
}
function update(next) {
var prev = opts;
opts = Object.assign({ background: "#0c1e18", accent: "#e6ff55", count: 6, spread: 0.75, fade: 0.32, sensitivity: 1 }, opts, next || {});
opts.count = Math.max(3, Math.min(8, Math.round(opts.count)));
wrap.style.background = opts.background;
wrap.style.setProperty("--ef-acc", opts.accent);
if (prev.count !== opts.count || prev.accent !== opts.accent) build();
measure();
}
bind();
update(o);
return { update: update, destroy: function () { clear(); stage.textContent = ""; cards = []; } };
}
// Usage
var fx = mount(document.querySelector("#my-root"), {"accent":"#e6ff55","count":6,"spread":0.75,"fade":0.32,"sensitivity":1});How it works
Every card has a position measured in card units and one shared scroll position, pos. For each card the script takes its offset from pos, wraps it into the range of minus half to plus half of the card count so the stack loops, then writes a translateY from that offset, a scale that shrinks with distance and an opacity that falls off with distance. Wheel, drag and arrow keys change pos. When input has been quiet for a moment the script picks the nearest whole card, including a little flick momentum, and eases pos toward it with an exponential approach, which is the continuous snap. The animation loop only runs while something is moving.
Accessibility and performance
The stack is one focusable group; the up and down arrow keys step a card at a time and a hidden live region announces the centred card. Wheel capture is limited to the element, so the page scrolls normally everywhere else, but because it traps vertical touch drags, leave room beside it for users to scroll past. With reduced motion the stack steps one card per wheel notch, swipe or key press with no easing. Text sits over gradients with a dark scrim; check contrast if you swap the artwork.
Each frame writes a transform and opacity on at most eight cards, and nothing else is animated. The loop stops as soon as the stack settles, and a ResizeObserver handles re-measuring. Cards far from the centre are hidden with visibility so they cost nothing to paint.
| Section | Gallery |
|---|---|
| Techniques | Carousel, Pointer interaction |
| Stack | Vanilla JS, CSS |
| Works with | React, Next.js, Vanilla |
| Dependencies | None |
| Status | tested |
| Inspired by | Infinite Scrolling Cards with GSAP and ScrollTrigger (continuous snap) by Codrops on Codrops. Original code, written from scratch. |
Find this technique on a real site
Want to see this technique in the wild? Check any site with the free tools, then build your own version from this page.
Check which animation library a site uses · Extract a site's design tokens · Browse site breakdowns
Licence and provenance
Free to use in personal and client projects during the preview. Final licence terms will be published here before any paid tier starts; ask before reselling the code as a collection.
This is original code. The technique is a common one that we studied by measuring award-winning sites with Enrichr; no third-party code, shaders, fonts or images are used.
More elements
FAQ
Does it hijack my page scroll?
No. The wheel listener is on the element itself, so it only captures the wheel while the pointer is over it. Scrolling anywhere else on the page is untouched.
Can I use real content in the cards?
Yes. Replace the artwork and text in the card builder with your own markup. The loop only needs each card to be a positioned box of the same height.
How do I change how strongly it snaps?
The ease rate in the animation loop controls how quickly it settles on a card, and the flick factor in the release step controls how far momentum carries before snapping.