36 Tailwind Stacked Cards
20 / 36

All 36 in 36 Tailwind Stacked Cards

  1. 01Tailwind Scroll-Driven Scaling Card Stack
  2. 02Tailwind Swipe Card Deck Drag to Dismiss
  3. 03Tailwind Glassmorphism Testimonial Card Stack
  4. 04Tailwind Stacked Cards Expand Accordion on Click
  5. 05Tailwind Fan Out Photo Stack on Hover
  6. 06Tailwind 3D Perspective Tilt Card Stack
  7. 07Tailwind Multi Step Form Card Stack
  8. 08Tailwind Tabbed Card Stack Bring to Front
  9. 09Tailwind Click to Front Stacked Cards Cycle
  10. 10Tailwind Classic Card Deck Stack Shadow
  11. 11Tailwind Fan Spread Hand of Cards
  12. 12Tailwind Polaroid Photo Stack Scattered Rotate
  13. 13Tailwind Staircase Offset Stacked Cards Cascade
  14. 14Tailwind Cards Spread Out Horizontally on Hover
  15. 15Tailwind Stacked Cards Lift on Hover Sibling Push
  16. 16Tailwind Accordion Stacked Cards Slide Out Menu
  17. 17Tailwind Shuffle Cards Animation Loop Deck
  18. 18Tailwind File Folder Tab Stacked Cards
  19. 19Tailwind Scattered Photos Pinch to Centre on Hover
  20. 20Tailwind Mobile Swipe Card Stack Translate3d
  21. 21Tailwind Single Accordion Card Nested Layers
  22. 22Tailwind Isometric Tower Stacked Cards 3D Column
  23. 23Tailwind 3D Flip Card Stack Reveal Back
  24. 24Tailwind Card Stack Tilt on Hover Quadrant
  25. 25Tailwind Notification Stack Pile Dismiss
  26. 26Tailwind Receipt Roll Unroll Stacked Items
  27. 27Tailwind Scratch Card Reveal Canvas Mask
  28. 28Tailwind Stacked Cards Hover Reveal Spread
  29. 29Tailwind Scroll Activated Sticky Stacking Cards
  30. 30Tailwind Stacked Testimonial Slider Radio
  31. 31Tailwind Isometric Overlapping Stacked Cards
  32. 32Tailwind Bento Grid Stacked Cards Hover Fan
  33. 33Tailwind Perspective Card Deck Depth Tunnel
  34. 34Tailwind Peel Back Corner Card Reveal
  35. 35Tailwind Timeline Stacked Milestone Cards
  36. 36Tailwind Spiral Tower Rotating Stacked Cards
Tailwind v4MIT licensed

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.

Live Demo
Plain CSSTailwind v4
Try it

Untitled UI Pro: 5,000+ React + Tailwind CSS v4 components · from $349 one-time

Untitled UI React · Affiliate link

Get Untitled UI Pro

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 &middot; 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 &middot; 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 &middot; 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 &middot; 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">&lsaquo;</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">&rsaquo;</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);
}
(() => {
  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.

1Load Tailwind
<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.

2Add the stylesheetRequiredthe @theme block is what makes the custom utilities exist
@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);
}
3Fonts used by this demooptional — skip if you have your own type stack
<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 in setup, not as an @import inside the stylesheet. Tailwind emits user @import rules after its own @layer blocks, and a late @import is invalid CSS — the browser drops it and the demo silently falls back to system fonts.
  • touch-action, will-change and the drag's transition-none all end up on the card itself, which is where anyone debugging a janky swipe actually looks.
  • Depth is one number driving translate3d, scale and [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 and data-[fly=true]: for the exit fade.
  • The live translate3d is 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=&quot;group&quot; 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.
Paste this into ChatGPT, Claude, Cursor, or any coding assistant. The block below is pre-framed with everything the AI needs to integrate this demo into your project — markup, styles, scoping notes, and the source URL. Hit Copy and paste straight into your chat.
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 &middot; 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 &middot; 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 &middot; 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 &middot; 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">&lsaquo;</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">&rsaquo;</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 THRESHOLD constant; 80px suits a thumb.
  • Change depth spacing via the 14px and 0.05 factors.
  • Make the deck finite by not re-appending in sendBack().
  • Swap the media and keep the width/height attributes — they hold CLS at zero.
  • Slow the settle with duration-450 on 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-change is 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

ChromeSafariFirefoxEdge
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.

Techniques used in this demo

Theme tokensset in @theme, used by a utility

Arbitraryneeds bracket syntax

Search CodeFronts

Loading…