20 / 36
All 36 in 36 Tailwind Stacked Cards
- 01Tailwind Scroll-Driven Scaling Card Stack
- 02Tailwind Swipe Card Deck Drag to Dismiss
- 03Tailwind Glassmorphism Testimonial Card Stack
- 04Tailwind Stacked Cards Expand Accordion on Click
- 05Tailwind Fan Out Photo Stack on Hover
- 06Tailwind 3D Perspective Tilt Card Stack
- 07Tailwind Multi Step Form Card Stack
- 08Tailwind Tabbed Card Stack Bring to Front
- 09Tailwind Click to Front Stacked Cards Cycle
- 10Tailwind Classic Card Deck Stack Shadow
- 11Tailwind Fan Spread Hand of Cards
- 12Tailwind Polaroid Photo Stack Scattered Rotate
- 13Tailwind Staircase Offset Stacked Cards Cascade
- 14Tailwind Cards Spread Out Horizontally on Hover
- 15Tailwind Stacked Cards Lift on Hover Sibling Push
- 16Tailwind Accordion Stacked Cards Slide Out Menu
- 17Tailwind Shuffle Cards Animation Loop Deck
- 18Tailwind File Folder Tab Stacked Cards
- 19Tailwind Scattered Photos Pinch to Centre on Hover
- 20Tailwind Mobile Swipe Card Stack Translate3d
- 21Tailwind Single Accordion Card Nested Layers
- 22Tailwind Isometric Tower Stacked Cards 3D Column
- 23Tailwind 3D Flip Card Stack Reveal Back
- 24Tailwind Card Stack Tilt on Hover Quadrant
- 25Tailwind Notification Stack Pile Dismiss
- 26Tailwind Receipt Roll Unroll Stacked Items
- 27Tailwind Scratch Card Reveal Canvas Mask
- 28Tailwind Stacked Cards Hover Reveal Spread
- 29Tailwind Scroll Activated Sticky Stacking Cards
- 30Tailwind Stacked Testimonial Slider Radio
- 31Tailwind Isometric Overlapping Stacked Cards
- 32Tailwind Bento Grid Stacked Cards Hover Fan
- 33Tailwind Perspective Card Deck Depth Tunnel
- 34Tailwind Peel Back Corner Card Reveal
- 35Tailwind Timeline Stacked Milestone Cards
- 36Tailwind Spiral Tower Rotating Stacked Cards
Tailwind Mobile Swipe Card Stack Translate3d
A thumb-first media deck that loops forever. Depth is one --d per card feeding a translate3d and a scale; the drag writes a live translate3d inline; and the commit flings the card off and re-appends it to the back. The port's contribution is that [touch-action:none], will-change-transform and data-[drag=true]:transition-none now sit on the card, where anyone debugging a janky swipe will look first.
Untitled UI Pro: 5,000+ React + Tailwind CSS v4 components · from $349 one-time
Untitled UI React · Affiliate link
The code
<section class="stk-20 w-full min-h-svh flex flex-col items-center justify-center gap-[22px] px-5 py-10 font-stk20-sans bg-stk20-bg" aria-label="Touch swipe media stack">
<div class="relative w-[min(300px,84vw)] h-95 focus-visible:outline-3 focus-visible:outline-stk20-ring focus-visible:outline-offset-8 focus-visible:rounded-[26px]" id="stk-20-deck" tabindex="0" role="group" aria-label="Photo deck. Swipe, or use the arrow keys.">
<article class="absolute inset-0 overflow-hidden rounded-[22px] bg-stk20-card cursor-grab select-none will-change-transform [touch-action:none] shadow-[0_24px_50px_-26px_rgba(0,0,0,0.85)] [transform:translate3d(0,calc(var(--d,0)*14px),0)_scale(calc(1-var(--d,0)*0.05))] [z-index:calc(10-var(--d,0))] transition-[transform,opacity] duration-450 ease-stk20 data-[drag=true]:transition-none data-[drag=true]:cursor-grabbing data-[fly=true]:opacity-0 motion-reduce:duration-200">
<img class="block size-full object-cover pointer-events-none" src="https://picsum.photos/seed/tw-sw-1/360/440" width="360" height="440" alt="Narrow lane in Kyoto at dusk">
<span class="absolute left-4 bottom-3.5 font-stk20-mono text-base font-bold text-white [text-shadow:0_2px_10px_rgba(0,0,0,0.6)]">Kyoto · JP</span>
</article>
<article class="absolute inset-0 overflow-hidden rounded-[22px] bg-stk20-card cursor-grab select-none will-change-transform [touch-action:none] shadow-[0_24px_50px_-26px_rgba(0,0,0,0.85)] [transform:translate3d(0,calc(var(--d,0)*14px),0)_scale(calc(1-var(--d,0)*0.05))] [z-index:calc(10-var(--d,0))] transition-[transform,opacity] duration-450 ease-stk20 data-[drag=true]:transition-none data-[drag=true]:cursor-grabbing data-[fly=true]:opacity-0 motion-reduce:duration-200">
<img class="block size-full object-cover pointer-events-none" src="https://picsum.photos/seed/tw-sw-2/360/440" width="360" height="440" alt="Red cabins on the Lofoten shoreline">
<span class="absolute left-4 bottom-3.5 font-stk20-mono text-base font-bold text-white [text-shadow:0_2px_10px_rgba(0,0,0,0.6)]">Lofoten · NO</span>
</article>
<article class="absolute inset-0 overflow-hidden rounded-[22px] bg-stk20-card cursor-grab select-none will-change-transform [touch-action:none] shadow-[0_24px_50px_-26px_rgba(0,0,0,0.85)] [transform:translate3d(0,calc(var(--d,0)*14px),0)_scale(calc(1-var(--d,0)*0.05))] [z-index:calc(10-var(--d,0))] transition-[transform,opacity] duration-450 ease-stk20 data-[drag=true]:transition-none data-[drag=true]:cursor-grabbing data-[fly=true]:opacity-0 motion-reduce:duration-200">
<img class="block size-full object-cover pointer-events-none" src="https://picsum.photos/seed/tw-sw-3/360/440" width="360" height="440" alt="Cracked salt flats in the Atacama">
<span class="absolute left-4 bottom-3.5 font-stk20-mono text-base font-bold text-white [text-shadow:0_2px_10px_rgba(0,0,0,0.6)]">Atacama · CL</span>
</article>
<article class="absolute inset-0 overflow-hidden rounded-[22px] bg-stk20-card cursor-grab select-none will-change-transform [touch-action:none] shadow-[0_24px_50px_-26px_rgba(0,0,0,0.85)] [transform:translate3d(0,calc(var(--d,0)*14px),0)_scale(calc(1-var(--d,0)*0.05))] [z-index:calc(10-var(--d,0))] transition-[transform,opacity] duration-450 ease-stk20 data-[drag=true]:transition-none data-[drag=true]:cursor-grabbing data-[fly=true]:opacity-0 motion-reduce:duration-200">
<img class="block size-full object-cover pointer-events-none" src="https://picsum.photos/seed/tw-sw-4/360/440" width="360" height="440" alt="Lake and peaks above Queenstown">
<span class="absolute left-4 bottom-3.5 font-stk20-mono text-base font-bold text-white [text-shadow:0_2px_10px_rgba(0,0,0,0.6)]">Queenstown · NZ</span>
</article>
</div>
<div class="flex items-center gap-4.5">
<button class="grid place-items-center size-11.5 rounded-full border border-stk20-line bg-stk20-btn text-2xl text-white cursor-pointer transition-colors duration-200 hover:bg-stk20-btnh focus-visible:outline-3 focus-visible:outline-stk20-ring focus-visible:outline-offset-2" id="stk-20-prev" type="button" aria-label="Previous card">‹</button>
<p class="m-0 min-w-15 text-center font-stk20-mono text-[13px] text-stk20-muted" id="stk-20-live" aria-live="polite"></p>
<button class="grid place-items-center size-11.5 rounded-full border border-stk20-line bg-stk20-btn text-2xl text-white cursor-pointer transition-colors duration-200 hover:bg-stk20-btnh focus-visible:outline-3 focus-visible:outline-stk20-ring focus-visible:outline-offset-2" id="stk-20-next" type="button" aria-label="Next card">›</button>
</div>
</section><section class="stk-20 w-full min-h-svh flex flex-col items-center justify-center gap-[22px] px-5 py-10 font-stk20-sans bg-stk20-bg" aria-label="Touch swipe media stack">
<div class="relative w-[min(300px,84vw)] h-95 focus-visible:outline-3 focus-visible:outline-stk20-ring focus-visible:outline-offset-8 focus-visible:rounded-[26px]" id="stk-20-deck" tabindex="0" role="group" aria-label="Photo deck. Swipe, or use the arrow keys.">
<article class="absolute inset-0 overflow-hidden rounded-[22px] bg-stk20-card cursor-grab select-none will-change-transform [touch-action:none] shadow-[0_24px_50px_-26px_rgba(0,0,0,0.85)] [transform:translate3d(0,calc(var(--d,0)*14px),0)_scale(calc(1-var(--d,0)*0.05))] [z-index:calc(10-var(--d,0))] transition-[transform,opacity] duration-450 ease-stk20 data-[drag=true]:transition-none data-[drag=true]:cursor-grabbing data-[fly=true]:opacity-0 motion-reduce:duration-200">
<img class="block size-full object-cover pointer-events-none" src="https://picsum.photos/seed/tw-sw-1/360/440" width="360" height="440" alt="Narrow lane in Kyoto at dusk">
<span class="absolute left-4 bottom-3.5 font-stk20-mono text-base font-bold text-white [text-shadow:0_2px_10px_rgba(0,0,0,0.6)]">Kyoto · JP</span>
</article>
<article class="absolute inset-0 overflow-hidden rounded-[22px] bg-stk20-card cursor-grab select-none will-change-transform [touch-action:none] shadow-[0_24px_50px_-26px_rgba(0,0,0,0.85)] [transform:translate3d(0,calc(var(--d,0)*14px),0)_scale(calc(1-var(--d,0)*0.05))] [z-index:calc(10-var(--d,0))] transition-[transform,opacity] duration-450 ease-stk20 data-[drag=true]:transition-none data-[drag=true]:cursor-grabbing data-[fly=true]:opacity-0 motion-reduce:duration-200">
<img class="block size-full object-cover pointer-events-none" src="https://picsum.photos/seed/tw-sw-2/360/440" width="360" height="440" alt="Red cabins on the Lofoten shoreline">
<span class="absolute left-4 bottom-3.5 font-stk20-mono text-base font-bold text-white [text-shadow:0_2px_10px_rgba(0,0,0,0.6)]">Lofoten · NO</span>
</article>
<article class="absolute inset-0 overflow-hidden rounded-[22px] bg-stk20-card cursor-grab select-none will-change-transform [touch-action:none] shadow-[0_24px_50px_-26px_rgba(0,0,0,0.85)] [transform:translate3d(0,calc(var(--d,0)*14px),0)_scale(calc(1-var(--d,0)*0.05))] [z-index:calc(10-var(--d,0))] transition-[transform,opacity] duration-450 ease-stk20 data-[drag=true]:transition-none data-[drag=true]:cursor-grabbing data-[fly=true]:opacity-0 motion-reduce:duration-200">
<img class="block size-full object-cover pointer-events-none" src="https://picsum.photos/seed/tw-sw-3/360/440" width="360" height="440" alt="Cracked salt flats in the Atacama">
<span class="absolute left-4 bottom-3.5 font-stk20-mono text-base font-bold text-white [text-shadow:0_2px_10px_rgba(0,0,0,0.6)]">Atacama · CL</span>
</article>
<article class="absolute inset-0 overflow-hidden rounded-[22px] bg-stk20-card cursor-grab select-none will-change-transform [touch-action:none] shadow-[0_24px_50px_-26px_rgba(0,0,0,0.85)] [transform:translate3d(0,calc(var(--d,0)*14px),0)_scale(calc(1-var(--d,0)*0.05))] [z-index:calc(10-var(--d,0))] transition-[transform,opacity] duration-450 ease-stk20 data-[drag=true]:transition-none data-[drag=true]:cursor-grabbing data-[fly=true]:opacity-0 motion-reduce:duration-200">
<img class="block size-full object-cover pointer-events-none" src="https://picsum.photos/seed/tw-sw-4/360/440" width="360" height="440" alt="Lake and peaks above Queenstown">
<span class="absolute left-4 bottom-3.5 font-stk20-mono text-base font-bold text-white [text-shadow:0_2px_10px_rgba(0,0,0,0.6)]">Queenstown · NZ</span>
</article>
</div>
<div class="flex items-center gap-4.5">
<button class="grid place-items-center size-11.5 rounded-full border border-stk20-line bg-stk20-btn text-2xl text-white cursor-pointer transition-colors duration-200 hover:bg-stk20-btnh focus-visible:outline-3 focus-visible:outline-stk20-ring focus-visible:outline-offset-2" id="stk-20-prev" type="button" aria-label="Previous card">‹</button>
<p class="m-0 min-w-15 text-center font-stk20-mono text-[13px] text-stk20-muted" id="stk-20-live" aria-live="polite"></p>
<button class="grid place-items-center size-11.5 rounded-full border border-stk20-line bg-stk20-btn text-2xl text-white cursor-pointer transition-colors duration-200 hover:bg-stk20-btnh focus-visible:outline-3 focus-visible:outline-stk20-ring focus-visible:outline-offset-2" id="stk-20-next" type="button" aria-label="Next card">›</button>
</div>
</section>@import "tailwindcss";
@theme {
--color-stk20-bg: #12131b;
--color-stk20-bg: oklch(0.20 0.014 275);
--color-stk20-card: #1d1f2a;
--color-stk20-card: oklch(0.25 0.016 275);
--color-stk20-btn: #20232f;
--color-stk20-btn: oklch(0.26 0.016 275);
--color-stk20-btnh: #2b2f3e;
--color-stk20-btnh: oklch(0.32 0.018 275);
--color-stk20-line: #333a4e;
--color-stk20-line: oklch(0.36 0.02 275);
--color-stk20-muted: #9aa0b4;
--color-stk20-muted: oklch(0.70 0.018 275);
--color-stk20-ring: #5aa2e0;
--color-stk20-ring: oklch(0.68 0.15 250);
--font-stk20-sans: 'Archivo', system-ui, sans-serif;
--font-stk20-mono: 'JetBrains Mono', ui-monospace, monospace;
--ease-stk20: cubic-bezier(0.3, 0.9, 0.3, 1);
}@import "tailwindcss";
@theme {
--color-stk20-bg: #12131b;
--color-stk20-bg: oklch(0.20 0.014 275);
--color-stk20-card: #1d1f2a;
--color-stk20-card: oklch(0.25 0.016 275);
--color-stk20-btn: #20232f;
--color-stk20-btn: oklch(0.26 0.016 275);
--color-stk20-btnh: #2b2f3e;
--color-stk20-btnh: oklch(0.32 0.018 275);
--color-stk20-line: #333a4e;
--color-stk20-line: oklch(0.36 0.02 275);
--color-stk20-muted: #9aa0b4;
--color-stk20-muted: oklch(0.70 0.018 275);
--color-stk20-ring: #5aa2e0;
--color-stk20-ring: oklch(0.68 0.15 250);
--font-stk20-sans: 'Archivo', system-ui, sans-serif;
--font-stk20-mono: 'JetBrains Mono', ui-monospace, monospace;
--ease-stk20: cubic-bezier(0.3, 0.9, 0.3, 1);
}(() => {
const root = document.querySelector('.stk-20');
if (!root) return;
const deck = root.querySelector('#stk-20-deck');
const live = root.querySelector('#stk-20-live');
const THRESHOLD = 80;
function paint() {
[...deck.children].forEach((c, i) => c.style.setProperty('--d', i));
const label = deck.firstElementChild.querySelector('span');
live.textContent = label ? label.textContent : '';
bind(deck.firstElementChild);
}
function sendBack(card) {
card.dataset.fly = 'true';
card.addEventListener('transitionend', () => {
delete card.dataset.fly;
card.style.transform = '';
deck.appendChild(card);
paint();
}, { once: true });
}
function bringFront() {
deck.insertBefore(deck.lastElementChild, deck.firstElementChild);
paint();
}
function bind(card) {
if (card.dataset.bound) return;
card.dataset.bound = '1';
let sx = 0, dx = 0, drag = false;
card.addEventListener('pointerdown', (e) => {
if (card !== deck.firstElementChild) return;
drag = true; sx = e.clientX;
card.setPointerCapture(e.pointerId);
card.dataset.drag = 'true';
});
/* translate3d, not translateX: the card gets its own composited layer, so
the drag never touches layout or paint. */
card.addEventListener('pointermove', (e) => {
if (!drag) return;
dx = e.clientX - sx;
card.style.transform = 'translate3d(' + dx + 'px,0,0) rotate(' + (dx / 22) + 'deg)';
});
card.addEventListener('pointerup', () => {
if (!drag) return;
drag = false;
delete card.dataset.drag;
if (Math.abs(dx) > THRESHOLD) {
const dir = dx > 0 ? 1 : -1;
card.style.transform = 'translate3d(' + dir * 140 + '%,0,0) rotate(' + dir * 18 + 'deg)';
sendBack(card);
} else {
card.style.transform = '';
}
dx = 0;
});
}
root.querySelector('#stk-20-next').addEventListener('click', () => {
const c = deck.firstElementChild;
c.style.transform = 'translate3d(-140%,0,0) rotate(-16deg)';
sendBack(c);
});
root.querySelector('#stk-20-prev').addEventListener('click', bringFront);
deck.addEventListener('keydown', (e) => {
if (e.key === 'ArrowLeft') { const c = deck.firstElementChild; c.style.transform = 'translate3d(-140%,0,0)'; sendBack(c); }
if (e.key === 'ArrowRight') bringFront();
});
paint();
})();(() => {
const root = document.querySelector('.stk-20');
if (!root) return;
const deck = root.querySelector('#stk-20-deck');
const live = root.querySelector('#stk-20-live');
const THRESHOLD = 80;
function paint() {
[...deck.children].forEach((c, i) => c.style.setProperty('--d', i));
const label = deck.firstElementChild.querySelector('span');
live.textContent = label ? label.textContent : '';
bind(deck.firstElementChild);
}
function sendBack(card) {
card.dataset.fly = 'true';
card.addEventListener('transitionend', () => {
delete card.dataset.fly;
card.style.transform = '';
deck.appendChild(card);
paint();
}, { once: true });
}
function bringFront() {
deck.insertBefore(deck.lastElementChild, deck.firstElementChild);
paint();
}
function bind(card) {
if (card.dataset.bound) return;
card.dataset.bound = '1';
let sx = 0, dx = 0, drag = false;
card.addEventListener('pointerdown', (e) => {
if (card !== deck.firstElementChild) return;
drag = true; sx = e.clientX;
card.setPointerCapture(e.pointerId);
card.dataset.drag = 'true';
});
/* translate3d, not translateX: the card gets its own composited layer, so
the drag never touches layout or paint. */
card.addEventListener('pointermove', (e) => {
if (!drag) return;
dx = e.clientX - sx;
card.style.transform = 'translate3d(' + dx + 'px,0,0) rotate(' + (dx / 22) + 'deg)';
});
card.addEventListener('pointerup', () => {
if (!drag) return;
drag = false;
delete card.dataset.drag;
if (Math.abs(dx) > THRESHOLD) {
const dir = dx > 0 ? 1 : -1;
card.style.transform = 'translate3d(' + dir * 140 + '%,0,0) rotate(' + dir * 18 + 'deg)';
sendBack(card);
} else {
card.style.transform = '';
}
dx = 0;
});
}
root.querySelector('#stk-20-next').addEventListener('click', () => {
const c = deck.firstElementChild;
c.style.transform = 'translate3d(-140%,0,0) rotate(-16deg)';
sendBack(c);
});
root.querySelector('#stk-20-prev').addEventListener('click', bringFront);
deck.addEventListener('keydown', (e) => {
if (e.key === 'ArrowLeft') { const c = deck.firstElementChild; c.style.transform = 'translate3d(-140%,0,0)'; sendBack(c); }
if (e.key === 'ArrowRight') bringFront();
});
paint();
})();Setup — do this once per project
Skip this if your project already runs Tailwind v4.
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>Compiles in the browser — perfect for a CodePen or a quick test, not for production.
npm install tailwindcss @tailwindcss/vite
npm install tailwindcss @tailwindcss/vite// vite.config.js ("type": "module" in package.json)
import { defineConfig } from 'vite'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [tailwindcss()],
})// vite.config.js ("type": "module" in package.json)
import { defineConfig } from 'vite'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [tailwindcss()],
})Any v4 setup works — Vite, PostCSS or the CLI. Nothing in this demo is bundler-specific.
@import "tailwindcss";
@theme {
--color-stk20-bg: #12131b;
--color-stk20-bg: oklch(0.20 0.014 275);
--color-stk20-card: #1d1f2a;
--color-stk20-card: oklch(0.25 0.016 275);
--color-stk20-btn: #20232f;
--color-stk20-btn: oklch(0.26 0.016 275);
--color-stk20-btnh: #2b2f3e;
--color-stk20-btnh: oklch(0.32 0.018 275);
--color-stk20-line: #333a4e;
--color-stk20-line: oklch(0.36 0.02 275);
--color-stk20-muted: #9aa0b4;
--color-stk20-muted: oklch(0.70 0.018 275);
--color-stk20-ring: #5aa2e0;
--color-stk20-ring: oklch(0.68 0.15 250);
--font-stk20-sans: 'Archivo', system-ui, sans-serif;
--font-stk20-mono: 'JetBrains Mono', ui-monospace, monospace;
--ease-stk20: cubic-bezier(0.3, 0.9, 0.3, 1);
}@import "tailwindcss";
@theme {
--color-stk20-bg: #12131b;
--color-stk20-bg: oklch(0.20 0.014 275);
--color-stk20-card: #1d1f2a;
--color-stk20-card: oklch(0.25 0.016 275);
--color-stk20-btn: #20232f;
--color-stk20-btn: oklch(0.26 0.016 275);
--color-stk20-btnh: #2b2f3e;
--color-stk20-btnh: oklch(0.32 0.018 275);
--color-stk20-line: #333a4e;
--color-stk20-line: oklch(0.36 0.02 275);
--color-stk20-muted: #9aa0b4;
--color-stk20-muted: oklch(0.70 0.018 275);
--color-stk20-ring: #5aa2e0;
--color-stk20-ring: oklch(0.68 0.15 250);
--font-stk20-sans: 'Archivo', system-ui, sans-serif;
--font-stk20-mono: 'JetBrains Mono', ui-monospace, monospace;
--ease-stk20: cubic-bezier(0.3, 0.9, 0.3, 1);
}<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Archivo:wdth,wght@75..125,400;75..125,800&family=JetBrains+Mono:wght@400;500&display=swap">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Archivo:wdth,wght@75..125,400;75..125,800&family=JetBrains+Mono:wght@400;500&display=swap">What changed in the port
Which parts became utilities, which stayed CSS, and why.
- The webfonts load as
<link>tags insetup, not as an@importinside the stylesheet. Tailwind emits user@importrules after its own@layerblocks, and a late@importis invalid CSS — the browser drops it and the demo silently falls back to system fonts. touch-action,will-changeand the drag'stransition-noneall end up on the card itself, which is where anyone debugging a janky swipe actually looks.- Depth is one number driving
translate3d,scaleand[z-index:calc(10-var(--d,0))]— the same pattern as demo 09, on the composited path. - Two class toggles become two data attributes:
data-[drag=true]:for the 1:1 tracking anddata-[fly=true]:for the exit fade. - The live
translate3dis the one inline style the demo needs, because its value is the pointer's position; every fixed destination stays in the markup or the handler's fling. - The deck itself is the focusable element with
role="group"and arrow-key handling, so a keyboard user drives the same cycle the thumb does. - The buttons are
size-11.5(46px) — deliberately above the 44px touch-target minimum, since this deck is designed thumb-first.
Here's a working CSS demo from CodeFronts. Use it as-is or adapt to your framework. All classes are scoped under a unique prefix so the code won't collide with your existing styles. MIT licensed.
Demo: Tailwind Mobile Swipe Card Stack Translate3d
Source: https://codefronts.com/components/tailwind-stacked-cards/tailwind-mobile-swipe-card-stack-translate3d/
A thumb-first media deck that loops forever. Depth is one --d per card feeding a translate3d and a scale; the drag writes a live translate3d inline; and the commit flings the card off and re-appends it to the back. The port's contribution is that [touch-action:none], will-change-transform and data-[drag=true]:transition-none now sit on the card, where anyone debugging a janky swipe will look first.
## HTML
```html
<section class="stk-20 w-full min-h-svh flex flex-col items-center justify-center gap-[22px] px-5 py-10 font-stk20-sans bg-stk20-bg" aria-label="Touch swipe media stack">
<div class="relative w-[min(300px,84vw)] h-95 focus-visible:outline-3 focus-visible:outline-stk20-ring focus-visible:outline-offset-8 focus-visible:rounded-[26px]" id="stk-20-deck" tabindex="0" role="group" aria-label="Photo deck. Swipe, or use the arrow keys.">
<article class="absolute inset-0 overflow-hidden rounded-[22px] bg-stk20-card cursor-grab select-none will-change-transform [touch-action:none] shadow-[0_24px_50px_-26px_rgba(0,0,0,0.85)] [transform:translate3d(0,calc(var(--d,0)*14px),0)_scale(calc(1-var(--d,0)*0.05))] [z-index:calc(10-var(--d,0))] transition-[transform,opacity] duration-450 ease-stk20 data-[drag=true]:transition-none data-[drag=true]:cursor-grabbing data-[fly=true]:opacity-0 motion-reduce:duration-200">
<img class="block size-full object-cover pointer-events-none" src="https://picsum.photos/seed/tw-sw-1/360/440" width="360" height="440" alt="Narrow lane in Kyoto at dusk">
<span class="absolute left-4 bottom-3.5 font-stk20-mono text-base font-bold text-white [text-shadow:0_2px_10px_rgba(0,0,0,0.6)]">Kyoto · JP</span>
</article>
<article class="absolute inset-0 overflow-hidden rounded-[22px] bg-stk20-card cursor-grab select-none will-change-transform [touch-action:none] shadow-[0_24px_50px_-26px_rgba(0,0,0,0.85)] [transform:translate3d(0,calc(var(--d,0)*14px),0)_scale(calc(1-var(--d,0)*0.05))] [z-index:calc(10-var(--d,0))] transition-[transform,opacity] duration-450 ease-stk20 data-[drag=true]:transition-none data-[drag=true]:cursor-grabbing data-[fly=true]:opacity-0 motion-reduce:duration-200">
<img class="block size-full object-cover pointer-events-none" src="https://picsum.photos/seed/tw-sw-2/360/440" width="360" height="440" alt="Red cabins on the Lofoten shoreline">
<span class="absolute left-4 bottom-3.5 font-stk20-mono text-base font-bold text-white [text-shadow:0_2px_10px_rgba(0,0,0,0.6)]">Lofoten · NO</span>
</article>
<article class="absolute inset-0 overflow-hidden rounded-[22px] bg-stk20-card cursor-grab select-none will-change-transform [touch-action:none] shadow-[0_24px_50px_-26px_rgba(0,0,0,0.85)] [transform:translate3d(0,calc(var(--d,0)*14px),0)_scale(calc(1-var(--d,0)*0.05))] [z-index:calc(10-var(--d,0))] transition-[transform,opacity] duration-450 ease-stk20 data-[drag=true]:transition-none data-[drag=true]:cursor-grabbing data-[fly=true]:opacity-0 motion-reduce:duration-200">
<img class="block size-full object-cover pointer-events-none" src="https://picsum.photos/seed/tw-sw-3/360/440" width="360" height="440" alt="Cracked salt flats in the Atacama">
<span class="absolute left-4 bottom-3.5 font-stk20-mono text-base font-bold text-white [text-shadow:0_2px_10px_rgba(0,0,0,0.6)]">Atacama · CL</span>
</article>
<article class="absolute inset-0 overflow-hidden rounded-[22px] bg-stk20-card cursor-grab select-none will-change-transform [touch-action:none] shadow-[0_24px_50px_-26px_rgba(0,0,0,0.85)] [transform:translate3d(0,calc(var(--d,0)*14px),0)_scale(calc(1-var(--d,0)*0.05))] [z-index:calc(10-var(--d,0))] transition-[transform,opacity] duration-450 ease-stk20 data-[drag=true]:transition-none data-[drag=true]:cursor-grabbing data-[fly=true]:opacity-0 motion-reduce:duration-200">
<img class="block size-full object-cover pointer-events-none" src="https://picsum.photos/seed/tw-sw-4/360/440" width="360" height="440" alt="Lake and peaks above Queenstown">
<span class="absolute left-4 bottom-3.5 font-stk20-mono text-base font-bold text-white [text-shadow:0_2px_10px_rgba(0,0,0,0.6)]">Queenstown · NZ</span>
</article>
</div>
<div class="flex items-center gap-4.5">
<button class="grid place-items-center size-11.5 rounded-full border border-stk20-line bg-stk20-btn text-2xl text-white cursor-pointer transition-colors duration-200 hover:bg-stk20-btnh focus-visible:outline-3 focus-visible:outline-stk20-ring focus-visible:outline-offset-2" id="stk-20-prev" type="button" aria-label="Previous card">‹</button>
<p class="m-0 min-w-15 text-center font-stk20-mono text-[13px] text-stk20-muted" id="stk-20-live" aria-live="polite"></p>
<button class="grid place-items-center size-11.5 rounded-full border border-stk20-line bg-stk20-btn text-2xl text-white cursor-pointer transition-colors duration-200 hover:bg-stk20-btnh focus-visible:outline-3 focus-visible:outline-stk20-ring focus-visible:outline-offset-2" id="stk-20-next" type="button" aria-label="Next card">›</button>
</div>
</section>
```
## CSS
```css
@import "tailwindcss";
@theme {
--color-stk20-bg: #12131b;
--color-stk20-bg: oklch(0.20 0.014 275);
--color-stk20-card: #1d1f2a;
--color-stk20-card: oklch(0.25 0.016 275);
--color-stk20-btn: #20232f;
--color-stk20-btn: oklch(0.26 0.016 275);
--color-stk20-btnh: #2b2f3e;
--color-stk20-btnh: oklch(0.32 0.018 275);
--color-stk20-line: #333a4e;
--color-stk20-line: oklch(0.36 0.02 275);
--color-stk20-muted: #9aa0b4;
--color-stk20-muted: oklch(0.70 0.018 275);
--color-stk20-ring: #5aa2e0;
--color-stk20-ring: oklch(0.68 0.15 250);
--font-stk20-sans: 'Archivo', system-ui, sans-serif;
--font-stk20-mono: 'JetBrains Mono', ui-monospace, monospace;
--ease-stk20: cubic-bezier(0.3, 0.9, 0.3, 1);
}
```
## JavaScript
```js
(() => {
const root = document.querySelector('.stk-20');
if (!root) return;
const deck = root.querySelector('#stk-20-deck');
const live = root.querySelector('#stk-20-live');
const THRESHOLD = 80;
function paint() {
[...deck.children].forEach((c, i) => c.style.setProperty('--d', i));
const label = deck.firstElementChild.querySelector('span');
live.textContent = label ? label.textContent : '';
bind(deck.firstElementChild);
}
function sendBack(card) {
card.dataset.fly = 'true';
card.addEventListener('transitionend', () => {
delete card.dataset.fly;
card.style.transform = '';
deck.appendChild(card);
paint();
}, { once: true });
}
function bringFront() {
deck.insertBefore(deck.lastElementChild, deck.firstElementChild);
paint();
}
function bind(card) {
if (card.dataset.bound) return;
card.dataset.bound = '1';
let sx = 0, dx = 0, drag = false;
card.addEventListener('pointerdown', (e) => {
if (card !== deck.firstElementChild) return;
drag = true; sx = e.clientX;
card.setPointerCapture(e.pointerId);
card.dataset.drag = 'true';
});
/* translate3d, not translateX: the card gets its own composited layer, so
the drag never touches layout or paint. */
card.addEventListener('pointermove', (e) => {
if (!drag) return;
dx = e.clientX - sx;
card.style.transform = 'translate3d(' + dx + 'px,0,0) rotate(' + (dx / 22) + 'deg)';
});
card.addEventListener('pointerup', () => {
if (!drag) return;
drag = false;
delete card.dataset.drag;
if (Math.abs(dx) > THRESHOLD) {
const dir = dx > 0 ? 1 : -1;
card.style.transform = 'translate3d(' + dir * 140 + '%,0,0) rotate(' + dir * 18 + 'deg)';
sendBack(card);
} else {
card.style.transform = '';
}
dx = 0;
});
}
root.querySelector('#stk-20-next').addEventListener('click', () => {
const c = deck.firstElementChild;
c.style.transform = 'translate3d(-140%,0,0) rotate(-16deg)';
sendBack(c);
});
root.querySelector('#stk-20-prev').addEventListener('click', bringFront);
deck.addEventListener('keydown', (e) => {
if (e.key === 'ArrowLeft') { const c = deck.firstElementChild; c.style.transform = 'translate3d(-140%,0,0)'; sendBack(c); }
if (e.key === 'ArrowRight') bringFront();
});
paint();
})();
```Here's a working CSS demo from CodeFronts. Use it as-is or adapt to your framework. All classes are scoped under a unique prefix so the code won't collide with your existing styles. MIT licensed.
Demo: Tailwind Mobile Swipe Card Stack Translate3d
Source: https://codefronts.com/components/tailwind-stacked-cards/tailwind-mobile-swipe-card-stack-translate3d/
A thumb-first media deck that loops forever. Depth is one --d per card feeding a translate3d and a scale; the drag writes a live translate3d inline; and the commit flings the card off and re-appends it to the back. The port's contribution is that [touch-action:none], will-change-transform and data-[drag=true]:transition-none now sit on the card, where anyone debugging a janky swipe will look first.
## HTML
```html
<section class="stk-20 w-full min-h-svh flex flex-col items-center justify-center gap-[22px] px-5 py-10 font-stk20-sans bg-stk20-bg" aria-label="Touch swipe media stack">
<div class="relative w-[min(300px,84vw)] h-95 focus-visible:outline-3 focus-visible:outline-stk20-ring focus-visible:outline-offset-8 focus-visible:rounded-[26px]" id="stk-20-deck" tabindex="0" role="group" aria-label="Photo deck. Swipe, or use the arrow keys.">
<article class="absolute inset-0 overflow-hidden rounded-[22px] bg-stk20-card cursor-grab select-none will-change-transform [touch-action:none] shadow-[0_24px_50px_-26px_rgba(0,0,0,0.85)] [transform:translate3d(0,calc(var(--d,0)*14px),0)_scale(calc(1-var(--d,0)*0.05))] [z-index:calc(10-var(--d,0))] transition-[transform,opacity] duration-450 ease-stk20 data-[drag=true]:transition-none data-[drag=true]:cursor-grabbing data-[fly=true]:opacity-0 motion-reduce:duration-200">
<img class="block size-full object-cover pointer-events-none" src="https://picsum.photos/seed/tw-sw-1/360/440" width="360" height="440" alt="Narrow lane in Kyoto at dusk">
<span class="absolute left-4 bottom-3.5 font-stk20-mono text-base font-bold text-white [text-shadow:0_2px_10px_rgba(0,0,0,0.6)]">Kyoto · JP</span>
</article>
<article class="absolute inset-0 overflow-hidden rounded-[22px] bg-stk20-card cursor-grab select-none will-change-transform [touch-action:none] shadow-[0_24px_50px_-26px_rgba(0,0,0,0.85)] [transform:translate3d(0,calc(var(--d,0)*14px),0)_scale(calc(1-var(--d,0)*0.05))] [z-index:calc(10-var(--d,0))] transition-[transform,opacity] duration-450 ease-stk20 data-[drag=true]:transition-none data-[drag=true]:cursor-grabbing data-[fly=true]:opacity-0 motion-reduce:duration-200">
<img class="block size-full object-cover pointer-events-none" src="https://picsum.photos/seed/tw-sw-2/360/440" width="360" height="440" alt="Red cabins on the Lofoten shoreline">
<span class="absolute left-4 bottom-3.5 font-stk20-mono text-base font-bold text-white [text-shadow:0_2px_10px_rgba(0,0,0,0.6)]">Lofoten · NO</span>
</article>
<article class="absolute inset-0 overflow-hidden rounded-[22px] bg-stk20-card cursor-grab select-none will-change-transform [touch-action:none] shadow-[0_24px_50px_-26px_rgba(0,0,0,0.85)] [transform:translate3d(0,calc(var(--d,0)*14px),0)_scale(calc(1-var(--d,0)*0.05))] [z-index:calc(10-var(--d,0))] transition-[transform,opacity] duration-450 ease-stk20 data-[drag=true]:transition-none data-[drag=true]:cursor-grabbing data-[fly=true]:opacity-0 motion-reduce:duration-200">
<img class="block size-full object-cover pointer-events-none" src="https://picsum.photos/seed/tw-sw-3/360/440" width="360" height="440" alt="Cracked salt flats in the Atacama">
<span class="absolute left-4 bottom-3.5 font-stk20-mono text-base font-bold text-white [text-shadow:0_2px_10px_rgba(0,0,0,0.6)]">Atacama · CL</span>
</article>
<article class="absolute inset-0 overflow-hidden rounded-[22px] bg-stk20-card cursor-grab select-none will-change-transform [touch-action:none] shadow-[0_24px_50px_-26px_rgba(0,0,0,0.85)] [transform:translate3d(0,calc(var(--d,0)*14px),0)_scale(calc(1-var(--d,0)*0.05))] [z-index:calc(10-var(--d,0))] transition-[transform,opacity] duration-450 ease-stk20 data-[drag=true]:transition-none data-[drag=true]:cursor-grabbing data-[fly=true]:opacity-0 motion-reduce:duration-200">
<img class="block size-full object-cover pointer-events-none" src="https://picsum.photos/seed/tw-sw-4/360/440" width="360" height="440" alt="Lake and peaks above Queenstown">
<span class="absolute left-4 bottom-3.5 font-stk20-mono text-base font-bold text-white [text-shadow:0_2px_10px_rgba(0,0,0,0.6)]">Queenstown · NZ</span>
</article>
</div>
<div class="flex items-center gap-4.5">
<button class="grid place-items-center size-11.5 rounded-full border border-stk20-line bg-stk20-btn text-2xl text-white cursor-pointer transition-colors duration-200 hover:bg-stk20-btnh focus-visible:outline-3 focus-visible:outline-stk20-ring focus-visible:outline-offset-2" id="stk-20-prev" type="button" aria-label="Previous card">‹</button>
<p class="m-0 min-w-15 text-center font-stk20-mono text-[13px] text-stk20-muted" id="stk-20-live" aria-live="polite"></p>
<button class="grid place-items-center size-11.5 rounded-full border border-stk20-line bg-stk20-btn text-2xl text-white cursor-pointer transition-colors duration-200 hover:bg-stk20-btnh focus-visible:outline-3 focus-visible:outline-stk20-ring focus-visible:outline-offset-2" id="stk-20-next" type="button" aria-label="Next card">›</button>
</div>
</section>
```
## CSS
```css
@import "tailwindcss";
@theme {
--color-stk20-bg: #12131b;
--color-stk20-bg: oklch(0.20 0.014 275);
--color-stk20-card: #1d1f2a;
--color-stk20-card: oklch(0.25 0.016 275);
--color-stk20-btn: #20232f;
--color-stk20-btn: oklch(0.26 0.016 275);
--color-stk20-btnh: #2b2f3e;
--color-stk20-btnh: oklch(0.32 0.018 275);
--color-stk20-line: #333a4e;
--color-stk20-line: oklch(0.36 0.02 275);
--color-stk20-muted: #9aa0b4;
--color-stk20-muted: oklch(0.70 0.018 275);
--color-stk20-ring: #5aa2e0;
--color-stk20-ring: oklch(0.68 0.15 250);
--font-stk20-sans: 'Archivo', system-ui, sans-serif;
--font-stk20-mono: 'JetBrains Mono', ui-monospace, monospace;
--ease-stk20: cubic-bezier(0.3, 0.9, 0.3, 1);
}
```
## JavaScript
```js
(() => {
const root = document.querySelector('.stk-20');
if (!root) return;
const deck = root.querySelector('#stk-20-deck');
const live = root.querySelector('#stk-20-live');
const THRESHOLD = 80;
function paint() {
[...deck.children].forEach((c, i) => c.style.setProperty('--d', i));
const label = deck.firstElementChild.querySelector('span');
live.textContent = label ? label.textContent : '';
bind(deck.firstElementChild);
}
function sendBack(card) {
card.dataset.fly = 'true';
card.addEventListener('transitionend', () => {
delete card.dataset.fly;
card.style.transform = '';
deck.appendChild(card);
paint();
}, { once: true });
}
function bringFront() {
deck.insertBefore(deck.lastElementChild, deck.firstElementChild);
paint();
}
function bind(card) {
if (card.dataset.bound) return;
card.dataset.bound = '1';
let sx = 0, dx = 0, drag = false;
card.addEventListener('pointerdown', (e) => {
if (card !== deck.firstElementChild) return;
drag = true; sx = e.clientX;
card.setPointerCapture(e.pointerId);
card.dataset.drag = 'true';
});
/* translate3d, not translateX: the card gets its own composited layer, so
the drag never touches layout or paint. */
card.addEventListener('pointermove', (e) => {
if (!drag) return;
dx = e.clientX - sx;
card.style.transform = 'translate3d(' + dx + 'px,0,0) rotate(' + (dx / 22) + 'deg)';
});
card.addEventListener('pointerup', () => {
if (!drag) return;
drag = false;
delete card.dataset.drag;
if (Math.abs(dx) > THRESHOLD) {
const dir = dx > 0 ? 1 : -1;
card.style.transform = 'translate3d(' + dir * 140 + '%,0,0) rotate(' + dir * 18 + 'deg)';
sendBack(card);
} else {
card.style.transform = '';
}
dx = 0;
});
}
root.querySelector('#stk-20-next').addEventListener('click', () => {
const c = deck.firstElementChild;
c.style.transform = 'translate3d(-140%,0,0) rotate(-16deg)';
sendBack(c);
});
root.querySelector('#stk-20-prev').addEventListener('click', bringFront);
deck.addEventListener('keydown', (e) => {
if (e.key === 'ArrowLeft') { const c = deck.firstElementChild; c.style.transform = 'translate3d(-140%,0,0)'; sendBack(c); }
if (e.key === 'ArrowRight') bringFront();
});
paint();
})();
```How this works
Three-dimensional translate, for a two-dimensional move. translate3d rather than translateX is a deliberate performance choice: it promotes the card to its own composited layer, so the drag never touches layout or paint. Paired with will-change-transform on the four cards — a small, bounded set — the swipe holds 60fps on mid-range phones, which is what protects INP.
The loop is a re-append. On commit the card flings out, and on transitionend it moves to the end of the deck and every --d is repainted. Because depth is a number rather than DOM order, the gallery never runs out and there is no index to keep.
Touch needs three declarations, and all three are on the card. [touch-action:none] stops the browser claiming the gesture; select-none stops the text selection that otherwise fires mid-drag; data-[drag=true]:transition-none stops the transition fighting the pointer. In the CSS version these are spread over three rules.
Swipe is never the only way in. Prev / Next buttons, arrow keys on the focused deck and a live position readout mean the deck is fully operable without a gesture — which matters for keyboard users, switch users and anyone on a desktop with a mouse.
Make it yours
- Set the commit distance with the
THRESHOLDconstant; 80px suits a thumb. - Change depth spacing via the
14pxand0.05factors. - Make the deck finite by not re-appending in
sendBack(). - Swap the media and keep the
width/heightattributes — they hold CLS at zero. - Slow the settle with
duration-450on the cards; the fling has its own faster path.
Gotchas — read before shipping
[touch-action:none]is mandatory. Without it mobile scrolls the page and the card barely moves.- Use
translate3d, and animate only transform and opacity. A width or margin animation here would show up directly in INP. will-changeis a budget, not a hint you sprinkle. Four cards is fine; forty would waste memory.- Clear the inline transform before re-appending, or the returning card keeps its fling position.
- Keep the buttons and the arrow keys. A swipe-only deck is unusable by a keyboard and invisible to a screen reader.
- Bind the pointer handlers to the top card only, and rebind after every reorder.
Browser support
| Chrome | Safari | Firefox | Edge |
|---|---|---|---|
| 111+ | 16.4+ | 128+ | 111+ |
Pointer Events and translate3d are universal (Chrome 55+, Safari 13+, Firefox 59+). The floor is Tailwind v4's own generated sheet.