36 Tailwind Stacked Cards
02 / 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 Swipe Card Deck Drag to Dismiss

The decision deck, ported to variants. The depth of the pile stops being three :nth-last-child rules and becomes nth-last-2:, nth-last-3: and nth-last-[n+4]: typed straight onto the card. The fly-off is a data-gone attribute the script sets, with the two exit transforms written as important arbitrary utilities so they beat the inline transform the drag leaves behind. The LIKE / NOPE stamps become real spans driven by group-data-[hint=yes]:, not content strings in a stylesheet.

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-02 w-full min-h-svh flex flex-col items-center justify-center gap-[22px] px-5 py-10 font-stk02-sans bg-stk02-bg [background-image:radial-gradient(circle_at_50%_0%,var(--color-stk02-glow),transparent_70%)]" aria-label="Swipeable card deck">
  <div class="relative w-[min(320px,86vw)] h-105" id="stk-02-deck">
    <p class="absolute inset-0 grid place-items-center p-8 text-center text-base text-stk02-muted">That&rsquo;s everyone for now.</p>
    <article class="group absolute inset-0 rounded-[22px] overflow-hidden bg-stk02-card select-none cursor-grab active:cursor-grabbing [touch-action:none] shadow-[0_24px_50px_-24px_rgba(0,0,0,0.75)] transition-[transform,opacity] duration-500 ease-stk02 nth-last-2:scale-[0.94] nth-last-2:translate-y-4 nth-last-3:scale-[0.88] nth-last-3:translate-y-8 nth-last-[n+4]:scale-[0.82] nth-last-[n+4]:translate-y-[46px] nth-last-[n+4]:opacity-0 data-[dragging=true]:transition-none data-[gone]:opacity-0 data-[gone=yes]:[transform:translate(140%,-10%)_rotate(22deg)]! data-[gone=no]:[transform:translate(-140%,-10%)_rotate(-22deg)]! motion-reduce:transition-[opacity] motion-reduce:duration-200 focus-visible:outline-3 focus-visible:outline-white focus-visible:outline-offset-3" tabindex="0">
      <img class="block w-full h-[74%] object-cover pointer-events-none" src="https://picsum.photos/seed/tw-swipe-1/400/460" width="400" height="460" alt="Mountain lake at first light">
      <div class="px-[18px] py-4 text-white">
        <h3 class="text-xl font-extrabold tracking-[-0.02em]">Aria, 27</h3>
        <p class="mt-0.5 text-[13px] text-stk02-muted">Trail runner &middot; Portland</p>
      </div>
      <span class="absolute top-[22px] left-[18px] rotate-[-14deg] rounded-lg border-[3px] border-stk02-yes px-3 py-1.5 font-stk02-mono text-[15px] font-bold tracking-[0.08em] text-stk02-yes opacity-0 transition-opacity duration-150 group-data-[hint=yes]:opacity-100" aria-hidden="true">LIKE</span>
      <span class="absolute top-[22px] right-[18px] rotate-[14deg] rounded-lg border-[3px] border-stk02-no px-3 py-1.5 font-stk02-mono text-[15px] font-bold tracking-[0.08em] text-stk02-no opacity-0 transition-opacity duration-150 group-data-[hint=no]:opacity-100" aria-hidden="true">NOPE</span>
    </article>
    <article class="group absolute inset-0 rounded-[22px] overflow-hidden bg-stk02-card select-none cursor-grab active:cursor-grabbing [touch-action:none] shadow-[0_24px_50px_-24px_rgba(0,0,0,0.75)] transition-[transform,opacity] duration-500 ease-stk02 nth-last-2:scale-[0.94] nth-last-2:translate-y-4 nth-last-3:scale-[0.88] nth-last-3:translate-y-8 nth-last-[n+4]:scale-[0.82] nth-last-[n+4]:translate-y-[46px] nth-last-[n+4]:opacity-0 data-[dragging=true]:transition-none data-[gone]:opacity-0 data-[gone=yes]:[transform:translate(140%,-10%)_rotate(22deg)]! data-[gone=no]:[transform:translate(-140%,-10%)_rotate(-22deg)]! motion-reduce:transition-[opacity] motion-reduce:duration-200 focus-visible:outline-3 focus-visible:outline-white focus-visible:outline-offset-3" tabindex="0">
      <img class="block w-full h-[74%] object-cover pointer-events-none" src="https://picsum.photos/seed/tw-swipe-2/400/460" width="400" height="460" alt="City rooftop in the evening">
      <div class="px-[18px] py-4 text-white">
        <h3 class="text-xl font-extrabold tracking-[-0.02em]">Devon, 31</h3>
        <p class="mt-0.5 text-[13px] text-stk02-muted">Coffee nerd &middot; Austin</p>
      </div>
      <span class="absolute top-[22px] left-[18px] rotate-[-14deg] rounded-lg border-[3px] border-stk02-yes px-3 py-1.5 font-stk02-mono text-[15px] font-bold tracking-[0.08em] text-stk02-yes opacity-0 transition-opacity duration-150 group-data-[hint=yes]:opacity-100" aria-hidden="true">LIKE</span>
      <span class="absolute top-[22px] right-[18px] rotate-[14deg] rounded-lg border-[3px] border-stk02-no px-3 py-1.5 font-stk02-mono text-[15px] font-bold tracking-[0.08em] text-stk02-no opacity-0 transition-opacity duration-150 group-data-[hint=no]:opacity-100" aria-hidden="true">NOPE</span>
    </article>
    <article class="group absolute inset-0 rounded-[22px] overflow-hidden bg-stk02-card select-none cursor-grab active:cursor-grabbing [touch-action:none] shadow-[0_24px_50px_-24px_rgba(0,0,0,0.75)] transition-[transform,opacity] duration-500 ease-stk02 nth-last-2:scale-[0.94] nth-last-2:translate-y-4 nth-last-3:scale-[0.88] nth-last-3:translate-y-8 nth-last-[n+4]:scale-[0.82] nth-last-[n+4]:translate-y-[46px] nth-last-[n+4]:opacity-0 data-[dragging=true]:transition-none data-[gone]:opacity-0 data-[gone=yes]:[transform:translate(140%,-10%)_rotate(22deg)]! data-[gone=no]:[transform:translate(-140%,-10%)_rotate(-22deg)]! motion-reduce:transition-[opacity] motion-reduce:duration-200 focus-visible:outline-3 focus-visible:outline-white focus-visible:outline-offset-3" tabindex="0">
      <img class="block w-full h-[74%] object-cover pointer-events-none" src="https://picsum.photos/seed/tw-swipe-3/400/460" width="400" height="460" alt="Empty desert road">
      <div class="px-[18px] py-4 text-white">
        <h3 class="text-xl font-extrabold tracking-[-0.02em]">Sky, 24</h3>
        <p class="mt-0.5 text-[13px] text-stk02-muted">Van-lifer &middot; Denver</p>
      </div>
      <span class="absolute top-[22px] left-[18px] rotate-[-14deg] rounded-lg border-[3px] border-stk02-yes px-3 py-1.5 font-stk02-mono text-[15px] font-bold tracking-[0.08em] text-stk02-yes opacity-0 transition-opacity duration-150 group-data-[hint=yes]:opacity-100" aria-hidden="true">LIKE</span>
      <span class="absolute top-[22px] right-[18px] rotate-[14deg] rounded-lg border-[3px] border-stk02-no px-3 py-1.5 font-stk02-mono text-[15px] font-bold tracking-[0.08em] text-stk02-no opacity-0 transition-opacity duration-150 group-data-[hint=no]:opacity-100" aria-hidden="true">NOPE</span>
    </article>
    <article class="group absolute inset-0 rounded-[22px] overflow-hidden bg-stk02-card select-none cursor-grab active:cursor-grabbing [touch-action:none] shadow-[0_24px_50px_-24px_rgba(0,0,0,0.75)] transition-[transform,opacity] duration-500 ease-stk02 nth-last-2:scale-[0.94] nth-last-2:translate-y-4 nth-last-3:scale-[0.88] nth-last-3:translate-y-8 nth-last-[n+4]:scale-[0.82] nth-last-[n+4]:translate-y-[46px] nth-last-[n+4]:opacity-0 data-[dragging=true]:transition-none data-[gone]:opacity-0 data-[gone=yes]:[transform:translate(140%,-10%)_rotate(22deg)]! data-[gone=no]:[transform:translate(-140%,-10%)_rotate(-22deg)]! motion-reduce:transition-[opacity] motion-reduce:duration-200 focus-visible:outline-3 focus-visible:outline-white focus-visible:outline-offset-3" tabindex="0">
      <img class="block w-full h-[74%] object-cover pointer-events-none" src="https://picsum.photos/seed/tw-swipe-4/400/460" width="400" height="460" alt="Forest path under tall pines">
      <div class="px-[18px] py-4 text-white">
        <h3 class="text-xl font-extrabold tracking-[-0.02em]">Rowan, 29</h3>
        <p class="mt-0.5 text-[13px] text-stk02-muted">Botanist &middot; Seattle</p>
      </div>
      <span class="absolute top-[22px] left-[18px] rotate-[-14deg] rounded-lg border-[3px] border-stk02-yes px-3 py-1.5 font-stk02-mono text-[15px] font-bold tracking-[0.08em] text-stk02-yes opacity-0 transition-opacity duration-150 group-data-[hint=yes]:opacity-100" aria-hidden="true">LIKE</span>
      <span class="absolute top-[22px] right-[18px] rotate-[14deg] rounded-lg border-[3px] border-stk02-no px-3 py-1.5 font-stk02-mono text-[15px] font-bold tracking-[0.08em] text-stk02-no opacity-0 transition-opacity duration-150 group-data-[hint=no]:opacity-100" aria-hidden="true">NOPE</span>
    </article>
  </div>
  <div class="flex gap-6">
    <button class="grid place-items-center size-15 rounded-full border-0 bg-stk02-no text-2xl text-white cursor-pointer shadow-[0_10px_24px_-10px_rgba(0,0,0,0.6)] transition-transform duration-150 hover:-translate-y-[3px] hover:scale-105 active:scale-95 focus-visible:outline-3 focus-visible:outline-white focus-visible:outline-offset-3" id="stk-02-no" type="button" aria-label="Pass on this card">&times;</button>
    <button class="grid place-items-center size-15 rounded-full border-0 bg-stk02-yes text-2xl text-white cursor-pointer shadow-[0_10px_24px_-10px_rgba(0,0,0,0.6)] transition-transform duration-150 hover:-translate-y-[3px] hover:scale-105 active:scale-95 focus-visible:outline-3 focus-visible:outline-white focus-visible:outline-offset-3" id="stk-02-yes" type="button" aria-label="Like this card">&hearts;</button>
  </div>
  <p class="m-0 font-stk02-mono text-[11px] tracking-[0.14em] uppercase text-stk02-muted" id="stk-02-count" aria-live="polite"></p>
</section>
@import "tailwindcss";

@theme {
  --color-stk02-bg: #15121f;
  --color-stk02-bg: oklch(0.19 0.025 295);
  --color-stk02-glow: #2a2340;
  --color-stk02-glow: oklch(0.28 0.045 295);
  --color-stk02-card: #241f36;
  --color-stk02-card: oklch(0.26 0.04 295);
  --color-stk02-muted: #c3bdd8;
  --color-stk02-muted: oklch(0.81 0.03 295);
  --color-stk02-yes: #45c98a;
  --color-stk02-yes: oklch(0.70 0.17 150);
  --color-stk02-no: #e2604a;
  --color-stk02-no: oklch(0.65 0.20 20);
  --font-stk02-sans: 'Archivo', system-ui, sans-serif;
  --font-stk02-mono: 'JetBrains Mono', ui-monospace, monospace;
  --ease-stk02: cubic-bezier(0.2, 0.9, 0.2, 1);
}
(() => {
  const root = document.querySelector('.stk-02');
  if (!root) return;
  const deck = root.querySelector('#stk-02-deck');
  const countEl = root.querySelector('#stk-02-count');
  const THRESHOLD = 90;

  const cards = () => [...deck.querySelectorAll('article')];
  const top = () => cards()[cards().length - 1] || null;

  function refresh() {
    const t = top();
    if (t) bind(t);
    const n = cards().length;
    countEl.textContent = n ? n + ' card' + (n > 1 ? 's' : '') + ' left' : 'Deck empty — reload to reshuffle';
  }

  /* State only: the exit transform is a data-[gone] utility in the markup, so the
     destination stays visible in the class list rather than hidden in a script. */
  function fling(card, dir) {
    card.style.transform = '';
    card.dataset.gone = dir > 0 ? 'yes' : 'no';
    card.addEventListener('transitionend', () => { card.remove(); refresh(); }, { once: true });
  }

  function bind(card) {
    if (card.dataset.bound) return;
    card.dataset.bound = '1';
    let startX = 0, dx = 0, dragging = false;

    card.addEventListener('pointerdown', (e) => {
      if (card !== top()) return;
      dragging = true; startX = e.clientX;
      card.setPointerCapture(e.pointerId);
      card.dataset.dragging = 'true';
    });

    /* The only inline style in the demo, and it has to be: this value is the
       pointer's live position and cannot exist as a static class. */
    card.addEventListener('pointermove', (e) => {
      if (!dragging) return;
      dx = e.clientX - startX;
      card.style.transform = 'translateX(' + dx + 'px) rotate(' + (dx / 18) + 'deg)';
      if (dx > 30) card.dataset.hint = 'yes';
      else if (dx < -30) card.dataset.hint = 'no';
      else delete card.dataset.hint;
    });

    card.addEventListener('pointerup', () => {
      if (!dragging) return;
      dragging = false;
      delete card.dataset.dragging;
      if (Math.abs(dx) > THRESHOLD) { fling(card, dx > 0 ? 1 : -1); }
      else { card.style.transform = ''; delete card.dataset.hint; }
      dx = 0;
    });

    card.addEventListener('keydown', (e) => {
      if (e.key === 'ArrowRight') fling(card, 1);
      if (e.key === 'ArrowLeft') fling(card, -1);
    });
  }

  root.querySelector('#stk-02-yes').addEventListener('click', () => { const t = top(); if (t) fling(t, 1); });
  root.querySelector('#stk-02-no').addEventListener('click', () => { const t = top(); if (t) fling(t, -1); });
  refresh();
})();

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-stk02-bg: #15121f;
  --color-stk02-bg: oklch(0.19 0.025 295);
  --color-stk02-glow: #2a2340;
  --color-stk02-glow: oklch(0.28 0.045 295);
  --color-stk02-card: #241f36;
  --color-stk02-card: oklch(0.26 0.04 295);
  --color-stk02-muted: #c3bdd8;
  --color-stk02-muted: oklch(0.81 0.03 295);
  --color-stk02-yes: #45c98a;
  --color-stk02-yes: oklch(0.70 0.17 150);
  --color-stk02-no: #e2604a;
  --color-stk02-no: oklch(0.65 0.20 20);
  --font-stk02-sans: 'Archivo', system-ui, sans-serif;
  --font-stk02-mono: 'JetBrains Mono', ui-monospace, monospace;
  --ease-stk02: cubic-bezier(0.2, 0.9, 0.2, 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,600;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.
  • The three :nth-last-child depth rules become nth-last-2:, nth-last-3: and nth-last-[n+4]: on the card itself — the pile's whole geometry is now readable in one class list.
  • The fly-off moves out of the script and into data-[gone=yes]:[transform:…]!; the ! is required because the drag leaves an inline transform that would otherwise win.
  • The LIKE / NOPE ::before/::after content rules become real aria-hidden spans revealed by group-data-[hint=yes]:opacity-100.
  • touch-action has no utility and stays arbitrary — [touch-action:none] next to cursor-grab, which is where a reader looking for gesture behaviour will look.
  • This stylesheet is only @theme. Every selector in the CSS version had a variant equivalent, which is unusual for a JS-driven demo and worth noticing.
  • The script sets attributes and removes nodes; it never adds a class and never reads one, so the visual contract stays entirely in the markup.
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 Swipe Card Deck Drag to Dismiss
Source: https://codefronts.com/components/tailwind-stacked-cards/tailwind-swipe-card-deck-drag-to-dismiss/

The decision deck, ported to variants. The depth of the pile stops being three :nth-last-child rules and becomes nth-last-2:, nth-last-3: and nth-last-[n+4]: typed straight onto the card. The fly-off is a data-gone attribute the script sets, with the two exit transforms written as important arbitrary utilities so they beat the inline transform the drag leaves behind. The LIKE / NOPE stamps become real spans driven by group-data-[hint=yes]:, not content strings in a stylesheet.
## HTML
```html
<section class="stk-02 w-full min-h-svh flex flex-col items-center justify-center gap-[22px] px-5 py-10 font-stk02-sans bg-stk02-bg [background-image:radial-gradient(circle_at_50%_0%,var(--color-stk02-glow),transparent_70%)]" aria-label="Swipeable card deck">
  <div class="relative w-[min(320px,86vw)] h-105" id="stk-02-deck">
    <p class="absolute inset-0 grid place-items-center p-8 text-center text-base text-stk02-muted">That&rsquo;s everyone for now.</p>
    <article class="group absolute inset-0 rounded-[22px] overflow-hidden bg-stk02-card select-none cursor-grab active:cursor-grabbing [touch-action:none] shadow-[0_24px_50px_-24px_rgba(0,0,0,0.75)] transition-[transform,opacity] duration-500 ease-stk02 nth-last-2:scale-[0.94] nth-last-2:translate-y-4 nth-last-3:scale-[0.88] nth-last-3:translate-y-8 nth-last-[n+4]:scale-[0.82] nth-last-[n+4]:translate-y-[46px] nth-last-[n+4]:opacity-0 data-[dragging=true]:transition-none data-[gone]:opacity-0 data-[gone=yes]:[transform:translate(140%,-10%)_rotate(22deg)]! data-[gone=no]:[transform:translate(-140%,-10%)_rotate(-22deg)]! motion-reduce:transition-[opacity] motion-reduce:duration-200 focus-visible:outline-3 focus-visible:outline-white focus-visible:outline-offset-3" tabindex="0">
      <img class="block w-full h-[74%] object-cover pointer-events-none" src="https://picsum.photos/seed/tw-swipe-1/400/460" width="400" height="460" alt="Mountain lake at first light">
      <div class="px-[18px] py-4 text-white">
        <h3 class="text-xl font-extrabold tracking-[-0.02em]">Aria, 27</h3>
        <p class="mt-0.5 text-[13px] text-stk02-muted">Trail runner &middot; Portland</p>
      </div>
      <span class="absolute top-[22px] left-[18px] rotate-[-14deg] rounded-lg border-[3px] border-stk02-yes px-3 py-1.5 font-stk02-mono text-[15px] font-bold tracking-[0.08em] text-stk02-yes opacity-0 transition-opacity duration-150 group-data-[hint=yes]:opacity-100" aria-hidden="true">LIKE</span>
      <span class="absolute top-[22px] right-[18px] rotate-[14deg] rounded-lg border-[3px] border-stk02-no px-3 py-1.5 font-stk02-mono text-[15px] font-bold tracking-[0.08em] text-stk02-no opacity-0 transition-opacity duration-150 group-data-[hint=no]:opacity-100" aria-hidden="true">NOPE</span>
    </article>
    <article class="group absolute inset-0 rounded-[22px] overflow-hidden bg-stk02-card select-none cursor-grab active:cursor-grabbing [touch-action:none] shadow-[0_24px_50px_-24px_rgba(0,0,0,0.75)] transition-[transform,opacity] duration-500 ease-stk02 nth-last-2:scale-[0.94] nth-last-2:translate-y-4 nth-last-3:scale-[0.88] nth-last-3:translate-y-8 nth-last-[n+4]:scale-[0.82] nth-last-[n+4]:translate-y-[46px] nth-last-[n+4]:opacity-0 data-[dragging=true]:transition-none data-[gone]:opacity-0 data-[gone=yes]:[transform:translate(140%,-10%)_rotate(22deg)]! data-[gone=no]:[transform:translate(-140%,-10%)_rotate(-22deg)]! motion-reduce:transition-[opacity] motion-reduce:duration-200 focus-visible:outline-3 focus-visible:outline-white focus-visible:outline-offset-3" tabindex="0">
      <img class="block w-full h-[74%] object-cover pointer-events-none" src="https://picsum.photos/seed/tw-swipe-2/400/460" width="400" height="460" alt="City rooftop in the evening">
      <div class="px-[18px] py-4 text-white">
        <h3 class="text-xl font-extrabold tracking-[-0.02em]">Devon, 31</h3>
        <p class="mt-0.5 text-[13px] text-stk02-muted">Coffee nerd &middot; Austin</p>
      </div>
      <span class="absolute top-[22px] left-[18px] rotate-[-14deg] rounded-lg border-[3px] border-stk02-yes px-3 py-1.5 font-stk02-mono text-[15px] font-bold tracking-[0.08em] text-stk02-yes opacity-0 transition-opacity duration-150 group-data-[hint=yes]:opacity-100" aria-hidden="true">LIKE</span>
      <span class="absolute top-[22px] right-[18px] rotate-[14deg] rounded-lg border-[3px] border-stk02-no px-3 py-1.5 font-stk02-mono text-[15px] font-bold tracking-[0.08em] text-stk02-no opacity-0 transition-opacity duration-150 group-data-[hint=no]:opacity-100" aria-hidden="true">NOPE</span>
    </article>
    <article class="group absolute inset-0 rounded-[22px] overflow-hidden bg-stk02-card select-none cursor-grab active:cursor-grabbing [touch-action:none] shadow-[0_24px_50px_-24px_rgba(0,0,0,0.75)] transition-[transform,opacity] duration-500 ease-stk02 nth-last-2:scale-[0.94] nth-last-2:translate-y-4 nth-last-3:scale-[0.88] nth-last-3:translate-y-8 nth-last-[n+4]:scale-[0.82] nth-last-[n+4]:translate-y-[46px] nth-last-[n+4]:opacity-0 data-[dragging=true]:transition-none data-[gone]:opacity-0 data-[gone=yes]:[transform:translate(140%,-10%)_rotate(22deg)]! data-[gone=no]:[transform:translate(-140%,-10%)_rotate(-22deg)]! motion-reduce:transition-[opacity] motion-reduce:duration-200 focus-visible:outline-3 focus-visible:outline-white focus-visible:outline-offset-3" tabindex="0">
      <img class="block w-full h-[74%] object-cover pointer-events-none" src="https://picsum.photos/seed/tw-swipe-3/400/460" width="400" height="460" alt="Empty desert road">
      <div class="px-[18px] py-4 text-white">
        <h3 class="text-xl font-extrabold tracking-[-0.02em]">Sky, 24</h3>
        <p class="mt-0.5 text-[13px] text-stk02-muted">Van-lifer &middot; Denver</p>
      </div>
      <span class="absolute top-[22px] left-[18px] rotate-[-14deg] rounded-lg border-[3px] border-stk02-yes px-3 py-1.5 font-stk02-mono text-[15px] font-bold tracking-[0.08em] text-stk02-yes opacity-0 transition-opacity duration-150 group-data-[hint=yes]:opacity-100" aria-hidden="true">LIKE</span>
      <span class="absolute top-[22px] right-[18px] rotate-[14deg] rounded-lg border-[3px] border-stk02-no px-3 py-1.5 font-stk02-mono text-[15px] font-bold tracking-[0.08em] text-stk02-no opacity-0 transition-opacity duration-150 group-data-[hint=no]:opacity-100" aria-hidden="true">NOPE</span>
    </article>
    <article class="group absolute inset-0 rounded-[22px] overflow-hidden bg-stk02-card select-none cursor-grab active:cursor-grabbing [touch-action:none] shadow-[0_24px_50px_-24px_rgba(0,0,0,0.75)] transition-[transform,opacity] duration-500 ease-stk02 nth-last-2:scale-[0.94] nth-last-2:translate-y-4 nth-last-3:scale-[0.88] nth-last-3:translate-y-8 nth-last-[n+4]:scale-[0.82] nth-last-[n+4]:translate-y-[46px] nth-last-[n+4]:opacity-0 data-[dragging=true]:transition-none data-[gone]:opacity-0 data-[gone=yes]:[transform:translate(140%,-10%)_rotate(22deg)]! data-[gone=no]:[transform:translate(-140%,-10%)_rotate(-22deg)]! motion-reduce:transition-[opacity] motion-reduce:duration-200 focus-visible:outline-3 focus-visible:outline-white focus-visible:outline-offset-3" tabindex="0">
      <img class="block w-full h-[74%] object-cover pointer-events-none" src="https://picsum.photos/seed/tw-swipe-4/400/460" width="400" height="460" alt="Forest path under tall pines">
      <div class="px-[18px] py-4 text-white">
        <h3 class="text-xl font-extrabold tracking-[-0.02em]">Rowan, 29</h3>
        <p class="mt-0.5 text-[13px] text-stk02-muted">Botanist &middot; Seattle</p>
      </div>
      <span class="absolute top-[22px] left-[18px] rotate-[-14deg] rounded-lg border-[3px] border-stk02-yes px-3 py-1.5 font-stk02-mono text-[15px] font-bold tracking-[0.08em] text-stk02-yes opacity-0 transition-opacity duration-150 group-data-[hint=yes]:opacity-100" aria-hidden="true">LIKE</span>
      <span class="absolute top-[22px] right-[18px] rotate-[14deg] rounded-lg border-[3px] border-stk02-no px-3 py-1.5 font-stk02-mono text-[15px] font-bold tracking-[0.08em] text-stk02-no opacity-0 transition-opacity duration-150 group-data-[hint=no]:opacity-100" aria-hidden="true">NOPE</span>
    </article>
  </div>
  <div class="flex gap-6">
    <button class="grid place-items-center size-15 rounded-full border-0 bg-stk02-no text-2xl text-white cursor-pointer shadow-[0_10px_24px_-10px_rgba(0,0,0,0.6)] transition-transform duration-150 hover:-translate-y-[3px] hover:scale-105 active:scale-95 focus-visible:outline-3 focus-visible:outline-white focus-visible:outline-offset-3" id="stk-02-no" type="button" aria-label="Pass on this card">&times;</button>
    <button class="grid place-items-center size-15 rounded-full border-0 bg-stk02-yes text-2xl text-white cursor-pointer shadow-[0_10px_24px_-10px_rgba(0,0,0,0.6)] transition-transform duration-150 hover:-translate-y-[3px] hover:scale-105 active:scale-95 focus-visible:outline-3 focus-visible:outline-white focus-visible:outline-offset-3" id="stk-02-yes" type="button" aria-label="Like this card">&hearts;</button>
  </div>
  <p class="m-0 font-stk02-mono text-[11px] tracking-[0.14em] uppercase text-stk02-muted" id="stk-02-count" aria-live="polite"></p>
</section>
```
## CSS
```css
@import "tailwindcss";

@theme {
  --color-stk02-bg: #15121f;
  --color-stk02-bg: oklch(0.19 0.025 295);
  --color-stk02-glow: #2a2340;
  --color-stk02-glow: oklch(0.28 0.045 295);
  --color-stk02-card: #241f36;
  --color-stk02-card: oklch(0.26 0.04 295);
  --color-stk02-muted: #c3bdd8;
  --color-stk02-muted: oklch(0.81 0.03 295);
  --color-stk02-yes: #45c98a;
  --color-stk02-yes: oklch(0.70 0.17 150);
  --color-stk02-no: #e2604a;
  --color-stk02-no: oklch(0.65 0.20 20);
  --font-stk02-sans: 'Archivo', system-ui, sans-serif;
  --font-stk02-mono: 'JetBrains Mono', ui-monospace, monospace;
  --ease-stk02: cubic-bezier(0.2, 0.9, 0.2, 1);
}
```

## JavaScript
```js
(() => {
  const root = document.querySelector('.stk-02');
  if (!root) return;
  const deck = root.querySelector('#stk-02-deck');
  const countEl = root.querySelector('#stk-02-count');
  const THRESHOLD = 90;

  const cards = () => [...deck.querySelectorAll('article')];
  const top = () => cards()[cards().length - 1] || null;

  function refresh() {
    const t = top();
    if (t) bind(t);
    const n = cards().length;
    countEl.textContent = n ? n + ' card' + (n > 1 ? 's' : '') + ' left' : 'Deck empty — reload to reshuffle';
  }

  /* State only: the exit transform is a data-[gone] utility in the markup, so the
     destination stays visible in the class list rather than hidden in a script. */
  function fling(card, dir) {
    card.style.transform = '';
    card.dataset.gone = dir > 0 ? 'yes' : 'no';
    card.addEventListener('transitionend', () => { card.remove(); refresh(); }, { once: true });
  }

  function bind(card) {
    if (card.dataset.bound) return;
    card.dataset.bound = '1';
    let startX = 0, dx = 0, dragging = false;

    card.addEventListener('pointerdown', (e) => {
      if (card !== top()) return;
      dragging = true; startX = e.clientX;
      card.setPointerCapture(e.pointerId);
      card.dataset.dragging = 'true';
    });

    /* The only inline style in the demo, and it has to be: this value is the
       pointer's live position and cannot exist as a static class. */
    card.addEventListener('pointermove', (e) => {
      if (!dragging) return;
      dx = e.clientX - startX;
      card.style.transform = 'translateX(' + dx + 'px) rotate(' + (dx / 18) + 'deg)';
      if (dx > 30) card.dataset.hint = 'yes';
      else if (dx < -30) card.dataset.hint = 'no';
      else delete card.dataset.hint;
    });

    card.addEventListener('pointerup', () => {
      if (!dragging) return;
      dragging = false;
      delete card.dataset.dragging;
      if (Math.abs(dx) > THRESHOLD) { fling(card, dx > 0 ? 1 : -1); }
      else { card.style.transform = ''; delete card.dataset.hint; }
      dx = 0;
    });

    card.addEventListener('keydown', (e) => {
      if (e.key === 'ArrowRight') fling(card, 1);
      if (e.key === 'ArrowLeft') fling(card, -1);
    });
  }

  root.querySelector('#stk-02-yes').addEventListener('click', () => { const t = top(); if (t) fling(t, 1); });
  root.querySelector('#stk-02-no').addEventListener('click', () => { const t = top(); if (t) fling(t, -1); });
  refresh();
})();
```

How this works

Pile depth is a structural variant, and this is the port's clearest win. The CSS version writes three :nth-last-child blocks that live far from the card they describe. In v4 the card says it itself:

class="… nth-last-2:scale-[0.94] nth-last-2:translate-y-4
       nth-last-3:scale-[0.88] nth-last-3:translate-y-8
       nth-last-[n+4]:scale-[0.82] nth-last-[n+4]:translate-y-[46px]
       nth-last-[n+4]:opacity-0"

Because the selector counts from the end, removing the top card automatically promotes everything beneath it — no index bookkeeping in the script at all.

The script writes state; the stylesheet writes the exit. During a drag the handler must set an inline transform: the value is the pointer's live position and cannot be a class. But the fling is a fixed destination, so it goes back into utility-land as data-gone:

data-[gone=yes]:[transform:translate(140%,-10%)_rotate(22deg)]!
data-[gone=no]:[transform:translate(-140%,-10%)_rotate(-22deg)]!
data-[gone]:opacity-0

The ! is load-bearing. An inline style normally beats a stylesheet, so without important the card would freeze at wherever the finger let go.

Stamps as elements, not pseudo-content. The CSS version paints LIKE and NOPE with ::before/::after and content. Tailwind can express that (before:content-['LIKE']) but should not here: real spans are inspectable, translatable, and can be marked aria-hidden so the stamp never doubles the announcement of a card the screen reader has already read.

Keyboard parity is not optional. Each card is focusable and answers Arrow Left / Arrow Right with the same fling the buttons trigger, and the remaining count sits in an aria-live="polite" region.

Make it yours

  • Change the swipe commitment distance via the THRESHOLD constant — 90px is a deliberate, forgiving default on touch.
  • Restyle the fly-off arc by editing the two data-[gone=…] arbitrary transforms; the rotation multiplier for the live drag lives in the script.
  • Deepen the pile by adding a nth-last-4: step and pushing the nth-last-[n+4]: cutoff out to [n+5].
  • Recolour the stamps and buttons through --color-stk02-yes / --color-stk02-no in @theme — both the stamp text, its border and the button fill read the same token.
  • Swap the images for your own and keep the width/height attributes; they are what hold CLS at zero.

Gotchas — read before shipping

  • The ! important modifier on the exit transforms is required, not stylistic. Inline styles outrank stylesheet rules, and the drag leaves an inline transform behind.
  • Bind pointer listeners to the current top card only, or drags leak to buried cards. The script rebinds on every removal and guards with a data-bound flag.
  • [touch-action:none] has no utility and is mandatory. Without it the browser claims the gesture and the page scrolls instead of the card moving.
  • Use pointer events rather than separate touch and mouse paths, and call setPointerCapture so a fast drag that leaves the card still delivers its pointerup.
  • nth-last-[n+4]: needs the bracket form — only small fixed positions have bare utilities (nth-last-3:).
  • Keep the button row. A deck that can only be driven by a drag gesture is unusable by keyboard and by anyone with a motor impairment.

Browser support

ChromeSafariFirefoxEdge
111+16.4+128+111+

Pointer Events and CSS transforms are universal (Chrome 55+, Safari 13+, Firefox 59+); the floor here is Tailwind v4's own generated sheet. :nth-last-child() is CSS3 and needs nothing modern.

Techniques used in this demo

Theme tokensset in @theme, used by a utility

Search CodeFronts

Loading…