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 "Portrait card gallery" for my site using the Enrichr Library element at https://enrichr.dev/elements/portrait-card-gallery.html.
Read the machine-readable spec and source at https://enrichr.dev/elements/portrait-card-gallery.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:
- interval: 4.5
- duration: 1.2
- gap: 105
- background: #0c1e18
- accent: #e6ff55
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/portrait-card-gallery.json.
Code
Mount it on an element that contains the markup. It returns an object with update(options) and destroy().
HTML
<div class="ef-pcg" role="region" aria-roledescription="carousel" aria-label="Corgi portrait gallery">
<div class="ef-pcg__glow" aria-hidden="true"></div>
<div class="ef-pcg__head">
<p class="ef-pcg__kicker">Enrichr Library</p>
<p class="ef-pcg__sub">A corgi edit</p>
</div>
<div class="ef-pcg__stage" tabindex="0" aria-label="Portrait gallery. Use the left and right arrow keys to move through the photos.">
<figure class="ef-pcg__card">
<img class="ef-pcg__img" src="/img/library/corgi-1.jpg" alt="Corgi puppy with huge ears on an orange backdrop" draggable="false">
<figcaption class="ef-pcg__cap"><span class="ef-pcg__tag">01 — Puppy</span> <span class="ef-pcg__title">All ears</span></figcaption>
</figure>
<figure class="ef-pcg__card">
<img class="ef-pcg__img" src="/img/library/corgi-2.jpg" alt="Smiling corgi in a field of purple and orange pansies" draggable="false">
<figcaption class="ef-pcg__cap"><span class="ef-pcg__tag">02 — Outdoors</span> <span class="ef-pcg__title">Flower field</span></figcaption>
</figure>
<figure class="ef-pcg__card">
<img class="ef-pcg__img" src="/img/library/corgi-3.jpg" alt="Corgi standing on a rock in front of autumn leaves" draggable="false">
<figcaption class="ef-pcg__cap"><span class="ef-pcg__tag">03 — Autumn</span> <span class="ef-pcg__title">On the rock</span></figcaption>
</figure>
<figure class="ef-pcg__card">
<img class="ef-pcg__img" src="/img/library/corgi-4.jpg" alt="Corgi running down a dirt road at sunset" style="object-position: 18% 50%" draggable="false">
<figcaption class="ef-pcg__cap"><span class="ef-pcg__tag">04 — Friends</span> <span class="ef-pcg__title">Dirt road run</span></figcaption>
</figure>
<figure class="ef-pcg__card">
<img class="ef-pcg__img" src="/img/library/corgi-6.jpg" alt="Corgi with its tongue out in a white studio" style="object-position: 48% 50%" draggable="false">
<figcaption class="ef-pcg__cap"><span class="ef-pcg__tag">05 — Studio</span> <span class="ef-pcg__title">Ready to play</span></figcaption>
</figure>
</div>
<button type="button" class="ef-pcg__btn ef-pcg__prev" aria-label="Previous photo"><svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><polyline points="15 18 9 12 15 6"/></svg></button>
<button type="button" class="ef-pcg__btn ef-pcg__next" aria-label="Next photo"><svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><polyline points="9 18 15 12 9 6"/></svg></button>
<div class="ef-pcg__bar" aria-hidden="true"><div class="ef-pcg__fill"></div></div>
<p class="ef-pcg__hint" aria-hidden="true">Swipe, click or use the arrow keys</p>
<p class="ef-pcg__live" aria-live="polite" aria-atomic="true"></p>
</div>CSS
/* Enrichr Library · https://enrichr.dev */
.ef-pcg { --ef-pcg-bg: #0c1e18; --ef-pcg-ink: #fbf8f1; --ef-pcg-acc: #e6ff55; --ef-pcg-deep: #050d0a; position: relative; box-sizing: border-box; width: 100%; height: 100%; min-height: 32rem; flex: 1 1 auto; overflow: hidden; border-radius: 1rem; background: var(--ef-pcg-bg); color: var(--ef-pcg-ink); font-family: "Instrument Sans", system-ui, sans-serif; user-select: none; }
.ef-pcg .ef-pcg__glow { position: absolute; top: 50%; left: 50%; width: 150%; height: 150%; background: radial-gradient(circle, color-mix(in srgb, var(--ef-pcg-ink) 3%, transparent) 0%, transparent 60%); transform: translate(-50%, -50%); pointer-events: none; }
.ef-pcg .ef-pcg__head { position: absolute; top: 2rem; left: 0; z-index: 50; width: 100%; text-align: center; text-shadow: 0 1px 10px rgba(0, 0, 0, 0.45); pointer-events: none; }
.ef-pcg .ef-pcg__kicker { margin: 0 0 0.25rem; font: 600 0.75rem/1rem "Parkinsans", "Space Grotesk", sans-serif; letter-spacing: 0.4em; text-transform: uppercase; color: color-mix(in srgb, var(--ef-pcg-ink) 58%, var(--ef-pcg-bg)); opacity: 1; }
.ef-pcg .ef-pcg__sub { margin: 0; font-size: 0.875rem; line-height: 1.25rem; font-weight: 400; letter-spacing: 0.5em; text-transform: uppercase; color: var(--ef-pcg-ink); opacity: 1; }
.ef-pcg .ef-pcg__stage { position: absolute; inset: 0; z-index: 10; outline: none; border-radius: inherit; touch-action: pan-y; }
.ef-pcg .ef-pcg__stage:focus-visible { box-shadow: inset 0 0 0 3px var(--ef-pcg-acc); }
.ef-pcg .ef-pcg__card { position: absolute; top: 50%; left: 50%; box-sizing: border-box; width: 360px; height: 640px; margin: 0; border-radius: 1.5rem; overflow: hidden; background: color-mix(in srgb, var(--ef-pcg-bg) 70%, var(--ef-pcg-deep)); box-shadow: 0 30px 60px -12px rgba(0, 0, 0, 0.8), 0 0 0 1px color-mix(in srgb, var(--ef-pcg-ink) 10%, transparent); cursor: pointer; transform: translate(-50%, -50%); will-change: transform, opacity; }
.ef-pcg .ef-pcg__img { display: block; width: 100%; height: 100%; object-fit: cover; pointer-events: none; transition: transform 0.7s ease-out; -webkit-user-drag: none; }
.ef-pcg .ef-pcg__card:hover .ef-pcg__img { transform: scale(1.05); }
.ef-pcg .ef-pcg__cap { position: absolute; left: 0; bottom: 0; box-sizing: border-box; width: 100%; margin: 0; padding: 2.5rem 1.5rem 1.5rem; background: linear-gradient(to top, color-mix(in srgb, var(--ef-pcg-deep) 90%, transparent) 0%, transparent 100%); opacity: 0; transform: translateY(20px); }
.ef-pcg .ef-pcg__tag { display: block; margin: 0 0 0.25rem; font-size: 0.75rem; line-height: 1rem; font-weight: 600; letter-spacing: 0.1em; text-transform: uppercase; color: color-mix(in srgb, var(--ef-pcg-ink) 80%, transparent); }
.ef-pcg .ef-pcg__title { display: block; margin: 0; font: 700 1.5rem/2rem "Parkinsans", "Space Grotesk", sans-serif; letter-spacing: -0.025em; color: var(--ef-pcg-ink); }
.ef-pcg .ef-pcg__bar { position: absolute; bottom: 2rem; left: 50%; z-index: 50; width: 200px; height: 2px; overflow: hidden; border-radius: 2px; background: color-mix(in srgb, var(--ef-pcg-ink) 10%, transparent); transform: translateX(-50%); }
.ef-pcg .ef-pcg__fill { width: 0%; height: 100%; background: var(--ef-pcg-acc); box-shadow: 0 0 10px color-mix(in srgb, var(--ef-pcg-acc) 50%, transparent); }
.ef-pcg .ef-pcg__hint { position: absolute; bottom: 1rem; left: 0; z-index: 50; width: 100%; margin: 0; text-align: center; font-size: 10px; line-height: 1; letter-spacing: 0.1em; text-transform: uppercase; color: color-mix(in srgb, var(--ef-pcg-ink) 45%, var(--ef-pcg-bg)); opacity: 1; pointer-events: none; }
.ef-pcg.is-still .ef-pcg__bar { display: none; }
/* Prev and Next stay out of sight until they get keyboard focus (the pen itself has no arrows) */
.ef-pcg .ef-pcg__btn { position: absolute; top: 50%; z-index: 60; display: grid; place-items: center; box-sizing: content-box; width: 24px; height: 24px; margin: 0; padding: 0.9rem; border: 0; border-radius: 50%; background: color-mix(in srgb, var(--ef-pcg-ink) 12%, transparent); -webkit-backdrop-filter: blur(12px); backdrop-filter: blur(12px); color: var(--ef-pcg-ink); cursor: pointer; transform: translateY(-50%); opacity: 0; pointer-events: none; }
.ef-pcg:has(:focus-visible) .ef-pcg__btn, .ef-pcg .ef-pcg__btn:focus-visible { opacity: 1; pointer-events: auto; }
.ef-pcg .ef-pcg__btn:focus-visible { outline: 3px solid var(--ef-pcg-acc); outline-offset: 3px; }
.ef-pcg .ef-pcg__prev { left: 1.25rem; }
.ef-pcg .ef-pcg__next { right: 1.25rem; }
.ef-pcg .ef-pcg__btn svg { width: 24px; height: 24px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
.ef-pcg .ef-pcg__live { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
@media (prefers-reduced-motion: reduce) { .ef-pcg .ef-pcg__img { transition: none; } .ef-pcg .ef-pcg__card:hover .ef-pcg__img { transform: none; } }JavaScript
// Enrichr Library · https://enrichr.dev
// Portrait card gallery: tall 9:16 photo cards in a row, the current one in the middle at full size with its caption, the
// others stepped out by 105% of a card width, each step 20% smaller and 40% fainter, on a loop (a card that wraps sweeps
// across behind the others). A move tweens every card's offset, scale and fade over 1.2 s on a power4 in-out curve; the
// caption of the new centre card rises in after 0.4 s (0.8 s, power3 out) while the old one drops away (0.6 s, power3 in).
// A thin bar fills over 4.5 s and then moves on. Click a side card, swipe, use the arrow keys, Home and End, or the Previous
// and Next buttons that appear when the gallery has keyboard focus; the photo in the middle is announced.
// Autoplay pauses on hover, on focus, off screen and in hidden tabs. Reduced motion: no autoplay, moves jump without tweens.
// mount(root, { interval (seconds per photo, 0 is off), duration (seconds per move), gap (% of a card width between cards),
// background, accent })
function mount(root, o) {
var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
var box = root.querySelector(".ef-pcg");
var stage = root.querySelector(".ef-pcg__stage");
var fill = root.querySelector(".ef-pcg__fill");
var live = root.querySelector(".ef-pcg__live");
var prev = root.querySelector(".ef-pcg__prev"), next = root.querySelector(".ef-pcg__next");
var items = [].slice.call(root.querySelectorAll(".ef-pcg__card")).map(function (el) {
return { el: el, capEl: el.querySelector(".ef-pcg__cap"), card: { v: { x: -50, s: 1, o: 1 }, tw: null, q: null }, cap: { v: { o: 0, y: 20 }, tw: null, q: null } };
});
var n = Math.max(1, items.length), idx = 0;
var opts = {}, clock = 0, last = 0, raf = 0, apStart = 0, swiped = false, drag = null;
var hover = false, focused = false, visible = true, destroyed = false, io = null, ro = null, offs = [];
function num(v, d) { v = Number(v); return isFinite(v) ? v : d; }
// GSAP eases: power4 is a quintic curve, power3 a quartic one
function p4io(p) { return p < 0.5 ? 16 * p * p * p * p * p : 1 - Math.pow(-2 * p + 2, 5) / 2; }
function p3o(p) { return 1 - Math.pow(1 - p, 4); }
function p3i(p) { return p * p * p * p; }
// a tween replaces the running one on the same channel; a delayed one waits and then takes over from where things are
function to(ch, target, dur, ease, delay) {
var t = { to: target, d: dur, e: ease, start: clock + (delay || 0), from: null };
if (delay > 0) ch.q = t; else { ch.q = null; ch.tw = t; }
}
function run(ch) {
if (ch.q && clock >= ch.q.start) { ch.tw = ch.q; ch.q = null; }
var t = ch.tw;
if (!t) return !!ch.q;
if (!t.from) { t.from = {}; for (var k in t.to) t.from[k] = ch.v[k]; }
var p = t.d > 0 ? Math.min(1, Math.max(0, (clock - t.start) / t.d)) : 1, e = t.e(p);
for (var j in t.to) ch.v[j] = t.from[j] + (t.to[j] - t.from[j]) * e;
if (p >= 1) ch.tw = null;
return true;
}
function paint(it) {
var c = it.card.v, q = it.cap.v;
it.el.style.transform = "translate(" + c.x.toFixed(3) + "%,-50%) scale(" + c.s.toFixed(4) + ")";
it.el.style.opacity = c.o.toFixed(4);
it.capEl.style.opacity = q.o.toFixed(4);
it.capEl.style.transform = "translateY(" + q.y.toFixed(2) + "px)";
}
function layout(instant) {
var half = Math.floor(n / 2);
items.forEach(function (it, i) {
var off = i - idx;
if (off > half) off -= n; else if (off < -half) off += n; // loop: the far card wraps to the other side
var a = Math.abs(off);
var t = { x: -50 + off * opts.gap, s: Math.max(0, 1 - a * 0.2), o: Math.max(0, 1 - a * 0.4) };
it.el.style.zIndex = String(10 - a);
if (instant) {
it.card.tw = it.card.q = it.cap.tw = it.cap.q = null;
it.card.v = t; it.cap.v = off === 0 ? { o: 1, y: 0 } : { o: 0, y: 20 };
paint(it);
} else {
to(it.card, t, opts.duration, p4io, 0);
if (off === 0) to(it.cap, { o: 1, y: 0 }, 0.8, p3o, 0.4);
else to(it.cap, { o: 0, y: 20 }, 0.6, p3i, 0);
}
it.el.setAttribute("role", "group");
it.el.setAttribute("aria-roledescription", "slide");
it.el.setAttribute("aria-label", (i + 1) + " of " + n);
if (off === 0) it.el.removeAttribute("aria-hidden"); else it.el.setAttribute("aria-hidden", "true");
});
}
function still() { return reduce || !(opts.interval > 0); }
function startAuto() { apStart = clock; fill.style.width = "0%"; }
function say() {
var it = items[idx], img = it.el.querySelector("img");
var cap = [].map.call(it.el.querySelectorAll(".ef-pcg__tag, .ef-pcg__title"), function (e) { return e.textContent.trim(); }).filter(Boolean).join(", ");
live.textContent = "Photo " + (idx + 1) + " of " + n + (cap ? ": " + cap : "") + (img && img.alt ? ". " + img.alt : "");
}
function show(i, user) {
idx = ((i % n) + n) % n;
layout(reduce);
startAuto();
if (user) say();
}
function navigate(dir, user) { show(idx + dir, user); }
function loop(now) {
raf = requestAnimationFrame(loop);
var dt = Math.min(0.1, (now - last) / 1000 || 0); last = now;
if (!visible || document.hidden) return;
clock += dt;
for (var i = 0; i < items.length; i++) {
var a = run(items[i].card), b = run(items[i].cap);
if (a || b) paint(items[i]);
}
if (!still()) {
if (hover || focused) apStart += dt; // hold the bar where it is
var p = (clock - apStart) / opts.interval;
fill.style.width = (Math.min(1, p) * 100).toFixed(2) + "%";
if (p >= 1) navigate(1, false);
}
}
function size() {
var W = Math.max(1, box.clientWidth), H = Math.max(1, box.clientHeight);
var cw = Math.min(W * 0.75, W >= 768 ? 360 : 320, H * 0.889 * 9 / 16);
items.forEach(function (it) { it.el.style.width = cw.toFixed(2) + "px"; it.el.style.height = (cw * 16 / 9).toFixed(2) + "px"; });
}
function onKey(e) {
var k = e.key;
if (k === "ArrowRight") { e.preventDefault(); navigate(1, true); }
else if (k === "ArrowLeft") { e.preventDefault(); navigate(-1, true); }
else if (k === "Home") { e.preventDefault(); show(0, true); }
else if (k === "End") { e.preventDefault(); show(n - 1, true); }
}
function down(e) { if (e.button !== undefined && e.button !== 0) return; drag = { x: e.clientX, id: e.pointerId }; }
function up(e) {
if (!drag || e.pointerId !== drag.id) return;
var dx = e.clientX - drag.x; drag = null;
if (Math.abs(dx) > 50) { swiped = true; setTimeout(function () { swiped = false; }, 0); navigate(dx < 0 ? 1 : -1, true); }
}
function cancel() { drag = null; }
function onEnter(e) { if (e.pointerType === "mouse") hover = true; }
function onLeave() { hover = false; }
function onFocusIn() { focused = true; }
function onFocusOut(e) { if (!box.contains(e.relatedTarget)) focused = false; }
function onPrev() { navigate(-1, true); }
function onNext() { navigate(1, true); }
items.forEach(function (it, i) {
function onClick() { if (!swiped && i !== idx) show(i, true); }
it.el.addEventListener("click", onClick);
offs.push(function () { it.el.removeEventListener("click", onClick); });
});
box.addEventListener("keydown", onKey);
stage.addEventListener("pointerdown", down);
stage.addEventListener("pointerup", up);
stage.addEventListener("pointercancel", cancel);
box.addEventListener("pointerenter", onEnter);
box.addEventListener("pointerleave", onLeave);
box.addEventListener("focusin", onFocusIn);
box.addEventListener("focusout", onFocusOut);
prev.addEventListener("click", onPrev);
next.addEventListener("click", onNext);
offs.push(function () {
box.removeEventListener("keydown", onKey);
stage.removeEventListener("pointerdown", down); stage.removeEventListener("pointerup", up); stage.removeEventListener("pointercancel", cancel);
box.removeEventListener("pointerenter", onEnter); box.removeEventListener("pointerleave", onLeave);
box.removeEventListener("focusin", onFocusIn); box.removeEventListener("focusout", onFocusOut);
prev.removeEventListener("click", onPrev); next.removeEventListener("click", onNext);
});
function update(partial) {
var was = opts.interval, wasGap = opts.gap;
opts = Object.assign({ interval: 4.5, duration: 1.2, gap: 105, background: "#0c1e18", accent: "#e6ff55" }, opts, partial || {});
opts.interval = Math.max(0, num(opts.interval, 4.5));
opts.duration = Math.max(0.05, num(opts.duration, 1.2));
opts.gap = Math.max(40, num(opts.gap, 105));
box.style.setProperty("--ef-pcg-bg", opts.background);
box.style.setProperty("--ef-pcg-acc", opts.accent);
box.classList.toggle("is-still", still());
if (destroyed) return;
if (was !== undefined && was !== opts.interval) startAuto();
size();
if (wasGap !== undefined && wasGap !== opts.gap) layout(reduce); // re-target with the new spacing
}
update(o);
// the pen starts with every card stacked in the middle and lets them spread out on the first move
items.forEach(paint);
layout(reduce);
startAuto();
if ("IntersectionObserver" in window) { io = new IntersectionObserver(function (e) { visible = e[0].isIntersecting; }); io.observe(box); }
if ("ResizeObserver" in window) { ro = new ResizeObserver(function () { if (!destroyed) size(); }); ro.observe(box); }
last = performance.now(); raf = requestAnimationFrame(loop);
return {
update: update,
destroy: function () {
destroyed = true; cancelAnimationFrame(raf);
offs.forEach(function (f) { f(); }); offs = [];
if (io) io.disconnect();
if (ro) ro.disconnect();
}
};
}
// Usage
var fx = mount(document.querySelector("#my-root"), {"interval":4.5,"duration":1.2,"gap":105,"background":"#0c1e18","accent":"#e6ff55"});How it works
Every card is placed by its offset from the current one around the loop, so the row has no ends. Each step out moves a card by 105% of a card width and makes it 20% smaller and 40% fainter. A move tweens every card to its new place over 1.2 seconds on a power4 in-out curve; the card that wraps from one end to the other sweeps across behind the rest. The caption on the new centre card rises 20 px and fades in after 0.4 seconds on a power3 out curve, while the old caption drops away on power3 in. A bar under the cards fills in 4.5 seconds and then moves on. The tweens are small requestAnimationFrame timers with the same curves and the same rules as the GSAP original: a new move takes over from wherever a card is. On first load the cards spread out from a stack in the middle.
Accessibility and performance
The gallery is a labelled carousel region with a focusable photo row: Left, Right, Home and End move it, and Previous and Next buttons appear when it has keyboard focus. The photo in the middle is announced with its caption and description in a polite live region; the other cards are hidden from assistive technology. Autoplay pauses on hover, on focus, off screen and in hidden tabs. With prefers-reduced-motion there is no autoplay and moves jump straight to their places.
Moves only change transform and opacity, which the browser composites without layout. One requestAnimationFrame loop drives all the tweens and the bar, and it stops counting off screen and in hidden tabs. A ResizeObserver resizes the cards to fit the box.
| Section | Gallery |
|---|---|
| Techniques | Carousel, Reveal and intro |
| Stack | Vanilla JS, CSS |
| Works with | React, Next.js, Vanilla |
| Dependencies | None |
| Status | tested |
| On CodePen | Open the live pen on our CodePen account; fork it to experiment. |
| Inspired by | Sleek Modern 9:16 Gallery by Enrichr on CodePen. 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
How do I change the photos and captions?
Edit the figures in the markup: the img src and alt, the small tag line and the title. The loop works with any number of cards; five to seven look best.
Can I turn off autoplay?
Set Time per photo to 0. The progress bar hides and the gallery only moves when someone swipes, clicks a side card or uses the keys.
Does swiping work with a mouse too?
Yes. A sideways drag of more than 50 px on the gallery moves one card, with a finger or a mouse. Vertical swipes still scroll the page.
Who took the photos?
Photos: Alvan Nee and fatty corgi on Unsplash, free to use under the Unsplash License.