17 / 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 Shuffle Cards Animation Loop Deck
A deck riffling itself, offset per card by a negative animation-delay so the loop never syncs. Hover and it stops dead and fans out to be read — group-hover:[animation-play-state:paused] plus a static fan transform. The reduced-motion path is the interesting one: it does not slow the loop down, it removes it and ships the fanned deck as the resting state.
Untitled UI Pro: 5,000+ React + Tailwind CSS v4 components · from $349 one-time
Untitled UI React · Affiliate link
The code
<section class="stk-17 w-full min-h-svh flex items-center justify-center px-5 py-15 font-stk17-sans bg-stk17-bg" aria-label="Shuffling card deck">
<div class="has-focus-visible:outline-3 has-focus-visible:outline-stk17-ring has-focus-visible:outline-offset-[12px] has-focus-visible:rounded-[22px]">
<div class="group relative w-37.5 h-50 outline-none" tabindex="0" role="img" aria-label="A deck of four suit cards, riffling. Hover or focus to pause and fan them out.">
<div class="absolute inset-0 grid place-items-center rounded-2xl bg-stk17-c1 text-[52px] text-white shadow-[0_16px_32px_-16px_rgba(0,0,0,0.8)] animate-stk17-riffle [animation-delay:calc(var(--i)*-0.4s)] group-hover:[animation-play-state:paused] group-hover:[transform:translateX(calc((var(--i)-1.5)*54px))_rotate(calc((var(--i)-1.5)*6deg))] group-focus-visible:[animation-play-state:paused] group-focus-visible:[transform:translateX(calc((var(--i)-1.5)*54px))_rotate(calc((var(--i)-1.5)*6deg))] motion-reduce:animate-none motion-reduce:[transform:translateX(calc((var(--i)-1.5)*54px))_rotate(calc((var(--i)-1.5)*6deg))]" style="--i:0" aria-hidden="true">♦</div>
<div class="absolute inset-0 grid place-items-center rounded-2xl bg-stk17-c2 text-[52px] text-white shadow-[0_16px_32px_-16px_rgba(0,0,0,0.8)] animate-stk17-riffle [animation-delay:calc(var(--i)*-0.4s)] group-hover:[animation-play-state:paused] group-hover:[transform:translateX(calc((var(--i)-1.5)*54px))_rotate(calc((var(--i)-1.5)*6deg))] group-focus-visible:[animation-play-state:paused] group-focus-visible:[transform:translateX(calc((var(--i)-1.5)*54px))_rotate(calc((var(--i)-1.5)*6deg))] motion-reduce:animate-none motion-reduce:[transform:translateX(calc((var(--i)-1.5)*54px))_rotate(calc((var(--i)-1.5)*6deg))]" style="--i:1" aria-hidden="true">♣</div>
<div class="absolute inset-0 grid place-items-center rounded-2xl bg-stk17-c3 text-[52px] text-white shadow-[0_16px_32px_-16px_rgba(0,0,0,0.8)] animate-stk17-riffle [animation-delay:calc(var(--i)*-0.4s)] group-hover:[animation-play-state:paused] group-hover:[transform:translateX(calc((var(--i)-1.5)*54px))_rotate(calc((var(--i)-1.5)*6deg))] group-focus-visible:[animation-play-state:paused] group-focus-visible:[transform:translateX(calc((var(--i)-1.5)*54px))_rotate(calc((var(--i)-1.5)*6deg))] motion-reduce:animate-none motion-reduce:[transform:translateX(calc((var(--i)-1.5)*54px))_rotate(calc((var(--i)-1.5)*6deg))]" style="--i:2" aria-hidden="true">♥</div>
<div class="absolute inset-0 grid place-items-center rounded-2xl bg-stk17-c4 text-[52px] text-white shadow-[0_16px_32px_-16px_rgba(0,0,0,0.8)] animate-stk17-riffle [animation-delay:calc(var(--i)*-0.4s)] group-hover:[animation-play-state:paused] group-hover:[transform:translateX(calc((var(--i)-1.5)*54px))_rotate(calc((var(--i)-1.5)*6deg))] group-focus-visible:[animation-play-state:paused] group-focus-visible:[transform:translateX(calc((var(--i)-1.5)*54px))_rotate(calc((var(--i)-1.5)*6deg))] motion-reduce:animate-none motion-reduce:[transform:translateX(calc((var(--i)-1.5)*54px))_rotate(calc((var(--i)-1.5)*6deg))]" style="--i:3" aria-hidden="true">♠</div>
</div>
</div>
</section><section class="stk-17 w-full min-h-svh flex items-center justify-center px-5 py-15 font-stk17-sans bg-stk17-bg" aria-label="Shuffling card deck">
<div class="has-focus-visible:outline-3 has-focus-visible:outline-stk17-ring has-focus-visible:outline-offset-[12px] has-focus-visible:rounded-[22px]">
<div class="group relative w-37.5 h-50 outline-none" tabindex="0" role="img" aria-label="A deck of four suit cards, riffling. Hover or focus to pause and fan them out.">
<div class="absolute inset-0 grid place-items-center rounded-2xl bg-stk17-c1 text-[52px] text-white shadow-[0_16px_32px_-16px_rgba(0,0,0,0.8)] animate-stk17-riffle [animation-delay:calc(var(--i)*-0.4s)] group-hover:[animation-play-state:paused] group-hover:[transform:translateX(calc((var(--i)-1.5)*54px))_rotate(calc((var(--i)-1.5)*6deg))] group-focus-visible:[animation-play-state:paused] group-focus-visible:[transform:translateX(calc((var(--i)-1.5)*54px))_rotate(calc((var(--i)-1.5)*6deg))] motion-reduce:animate-none motion-reduce:[transform:translateX(calc((var(--i)-1.5)*54px))_rotate(calc((var(--i)-1.5)*6deg))]" style="--i:0" aria-hidden="true">♦</div>
<div class="absolute inset-0 grid place-items-center rounded-2xl bg-stk17-c2 text-[52px] text-white shadow-[0_16px_32px_-16px_rgba(0,0,0,0.8)] animate-stk17-riffle [animation-delay:calc(var(--i)*-0.4s)] group-hover:[animation-play-state:paused] group-hover:[transform:translateX(calc((var(--i)-1.5)*54px))_rotate(calc((var(--i)-1.5)*6deg))] group-focus-visible:[animation-play-state:paused] group-focus-visible:[transform:translateX(calc((var(--i)-1.5)*54px))_rotate(calc((var(--i)-1.5)*6deg))] motion-reduce:animate-none motion-reduce:[transform:translateX(calc((var(--i)-1.5)*54px))_rotate(calc((var(--i)-1.5)*6deg))]" style="--i:1" aria-hidden="true">♣</div>
<div class="absolute inset-0 grid place-items-center rounded-2xl bg-stk17-c3 text-[52px] text-white shadow-[0_16px_32px_-16px_rgba(0,0,0,0.8)] animate-stk17-riffle [animation-delay:calc(var(--i)*-0.4s)] group-hover:[animation-play-state:paused] group-hover:[transform:translateX(calc((var(--i)-1.5)*54px))_rotate(calc((var(--i)-1.5)*6deg))] group-focus-visible:[animation-play-state:paused] group-focus-visible:[transform:translateX(calc((var(--i)-1.5)*54px))_rotate(calc((var(--i)-1.5)*6deg))] motion-reduce:animate-none motion-reduce:[transform:translateX(calc((var(--i)-1.5)*54px))_rotate(calc((var(--i)-1.5)*6deg))]" style="--i:2" aria-hidden="true">♥</div>
<div class="absolute inset-0 grid place-items-center rounded-2xl bg-stk17-c4 text-[52px] text-white shadow-[0_16px_32px_-16px_rgba(0,0,0,0.8)] animate-stk17-riffle [animation-delay:calc(var(--i)*-0.4s)] group-hover:[animation-play-state:paused] group-hover:[transform:translateX(calc((var(--i)-1.5)*54px))_rotate(calc((var(--i)-1.5)*6deg))] group-focus-visible:[animation-play-state:paused] group-focus-visible:[transform:translateX(calc((var(--i)-1.5)*54px))_rotate(calc((var(--i)-1.5)*6deg))] motion-reduce:animate-none motion-reduce:[transform:translateX(calc((var(--i)-1.5)*54px))_rotate(calc((var(--i)-1.5)*6deg))]" style="--i:3" aria-hidden="true">♠</div>
</div>
</div>
</section>@import "tailwindcss";
@theme {
--color-stk17-bg: #131620;
--color-stk17-bg: oklch(0.21 0.018 275);
--color-stk17-ring: #45ab7c;
--color-stk17-ring: oklch(0.66 0.15 150);
--color-stk17-c1: #cf5f4b;
--color-stk17-c1: oklch(0.62 0.16 20);
--color-stk17-c2: #45ab7c;
--color-stk17-c2: oklch(0.64 0.15 150);
--color-stk17-c3: #4d8ade;
--color-stk17-c3: oklch(0.62 0.16 250);
--color-stk17-c4: #9b6ed6;
--color-stk17-c4: oklch(0.62 0.16 300);
--font-stk17-sans: 'Archivo', system-ui, sans-serif;
--animate-stk17-riffle: stk-17-riffle 1.6s ease-in-out infinite;
}
/* The riffle path itself. z-index is not interpolated inside keyframes — it
steps at each stop, which is exactly what a shuffle wants. */
@keyframes stk-17-riffle {
0%,
100% {
transform: translateX(0) rotate(0);
z-index: 2;
}
25% {
transform: translateX(70px) rotate(10deg);
z-index: 5;
}
50% {
transform: translateX(0) rotate(0);
z-index: 2;
}
75% {
transform: translateX(-70px) rotate(-10deg);
z-index: 1;
}
}@import "tailwindcss";
@theme {
--color-stk17-bg: #131620;
--color-stk17-bg: oklch(0.21 0.018 275);
--color-stk17-ring: #45ab7c;
--color-stk17-ring: oklch(0.66 0.15 150);
--color-stk17-c1: #cf5f4b;
--color-stk17-c1: oklch(0.62 0.16 20);
--color-stk17-c2: #45ab7c;
--color-stk17-c2: oklch(0.64 0.15 150);
--color-stk17-c3: #4d8ade;
--color-stk17-c3: oklch(0.62 0.16 250);
--color-stk17-c4: #9b6ed6;
--color-stk17-c4: oklch(0.62 0.16 300);
--font-stk17-sans: 'Archivo', system-ui, sans-serif;
--animate-stk17-riffle: stk-17-riffle 1.6s ease-in-out infinite;
}
/* The riffle path itself. z-index is not interpolated inside keyframes — it
steps at each stop, which is exactly what a shuffle wants. */
@keyframes stk-17-riffle {
0%,
100% {
transform: translateX(0) rotate(0);
z-index: 2;
}
25% {
transform: translateX(70px) rotate(10deg);
z-index: 5;
}
50% {
transform: translateX(0) rotate(0);
z-index: 2;
}
75% {
transform: translateX(-70px) rotate(-10deg);
z-index: 1;
}
}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-stk17-bg: #131620;
--color-stk17-bg: oklch(0.21 0.018 275);
--color-stk17-ring: #45ab7c;
--color-stk17-ring: oklch(0.66 0.15 150);
--color-stk17-c1: #cf5f4b;
--color-stk17-c1: oklch(0.62 0.16 20);
--color-stk17-c2: #45ab7c;
--color-stk17-c2: oklch(0.64 0.15 150);
--color-stk17-c3: #4d8ade;
--color-stk17-c3: oklch(0.62 0.16 250);
--color-stk17-c4: #9b6ed6;
--color-stk17-c4: oklch(0.62 0.16 300);
--font-stk17-sans: 'Archivo', system-ui, sans-serif;
--animate-stk17-riffle: stk-17-riffle 1.6s ease-in-out infinite;
}
/* The riffle path itself. z-index is not interpolated inside keyframes — it
steps at each stop, which is exactly what a shuffle wants. */
@keyframes stk-17-riffle {
0%,
100% {
transform: translateX(0) rotate(0);
z-index: 2;
}
25% {
transform: translateX(70px) rotate(10deg);
z-index: 5;
}
50% {
transform: translateX(0) rotate(0);
z-index: 2;
}
75% {
transform: translateX(-70px) rotate(-10deg);
z-index: 1;
}
}@import "tailwindcss";
@theme {
--color-stk17-bg: #131620;
--color-stk17-bg: oklch(0.21 0.018 275);
--color-stk17-ring: #45ab7c;
--color-stk17-ring: oklch(0.66 0.15 150);
--color-stk17-c1: #cf5f4b;
--color-stk17-c1: oklch(0.62 0.16 20);
--color-stk17-c2: #45ab7c;
--color-stk17-c2: oklch(0.64 0.15 150);
--color-stk17-c3: #4d8ade;
--color-stk17-c3: oklch(0.62 0.16 250);
--color-stk17-c4: #9b6ed6;
--color-stk17-c4: oklch(0.62 0.16 300);
--font-stk17-sans: 'Archivo', system-ui, sans-serif;
--animate-stk17-riffle: stk-17-riffle 1.6s ease-in-out infinite;
}
/* The riffle path itself. z-index is not interpolated inside keyframes — it
steps at each stop, which is exactly what a shuffle wants. */
@keyframes stk-17-riffle {
0%,
100% {
transform: translateX(0) rotate(0);
z-index: 2;
}
25% {
transform: translateX(70px) rotate(10deg);
z-index: 5;
}
50% {
transform: translateX(0) rotate(0);
z-index: 2;
}
75% {
transform: translateX(-70px) rotate(-10deg);
z-index: 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&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&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. - The loop is a
--animate-*token, so duration, easing andinfiniteall arrive with oneanimate-stk17-riffleclass. [animation-delay:calc(var(--i)*-0.4s)]replaces four:nth-childrules, and the negative sign is the mechanism: it starts each card mid-cycle rather than staggering the starts.animation-play-statehas no utility, sogroup-hover:[animation-play-state:paused]is the idiomatic form — and pausing rather than removing is what lets the fan transform take over cleanly.- The fan is a centred signed-index expression,
calc((var(--i)-1.5)*54px), so one class fans four cards symmetrically. - The reduced-motion path ships the fanned, readable state as the default rather than a slower riffle — the correct accommodation for purely decorative looping motion.
- The deck carries
role="img"with a description, and the individual suit cards arearia-hidden: what matters to a screen reader is what the thing is, not four unlabelled glyphs.
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 Shuffle Cards Animation Loop Deck
Source: https://codefronts.com/components/tailwind-stacked-cards/tailwind-shuffle-cards-animation-loop-deck/
A deck riffling itself, offset per card by a negative animation-delay so the loop never syncs. Hover and it stops dead and fans out to be read — group-hover:[animation-play-state:paused] plus a static fan transform. The reduced-motion path is the interesting one: it does not slow the loop down, it removes it and ships the fanned deck as the resting state.
## HTML
```html
<section class="stk-17 w-full min-h-svh flex items-center justify-center px-5 py-15 font-stk17-sans bg-stk17-bg" aria-label="Shuffling card deck">
<div class="has-focus-visible:outline-3 has-focus-visible:outline-stk17-ring has-focus-visible:outline-offset-[12px] has-focus-visible:rounded-[22px]">
<div class="group relative w-37.5 h-50 outline-none" tabindex="0" role="img" aria-label="A deck of four suit cards, riffling. Hover or focus to pause and fan them out.">
<div class="absolute inset-0 grid place-items-center rounded-2xl bg-stk17-c1 text-[52px] text-white shadow-[0_16px_32px_-16px_rgba(0,0,0,0.8)] animate-stk17-riffle [animation-delay:calc(var(--i)*-0.4s)] group-hover:[animation-play-state:paused] group-hover:[transform:translateX(calc((var(--i)-1.5)*54px))_rotate(calc((var(--i)-1.5)*6deg))] group-focus-visible:[animation-play-state:paused] group-focus-visible:[transform:translateX(calc((var(--i)-1.5)*54px))_rotate(calc((var(--i)-1.5)*6deg))] motion-reduce:animate-none motion-reduce:[transform:translateX(calc((var(--i)-1.5)*54px))_rotate(calc((var(--i)-1.5)*6deg))]" style="--i:0" aria-hidden="true">♦</div>
<div class="absolute inset-0 grid place-items-center rounded-2xl bg-stk17-c2 text-[52px] text-white shadow-[0_16px_32px_-16px_rgba(0,0,0,0.8)] animate-stk17-riffle [animation-delay:calc(var(--i)*-0.4s)] group-hover:[animation-play-state:paused] group-hover:[transform:translateX(calc((var(--i)-1.5)*54px))_rotate(calc((var(--i)-1.5)*6deg))] group-focus-visible:[animation-play-state:paused] group-focus-visible:[transform:translateX(calc((var(--i)-1.5)*54px))_rotate(calc((var(--i)-1.5)*6deg))] motion-reduce:animate-none motion-reduce:[transform:translateX(calc((var(--i)-1.5)*54px))_rotate(calc((var(--i)-1.5)*6deg))]" style="--i:1" aria-hidden="true">♣</div>
<div class="absolute inset-0 grid place-items-center rounded-2xl bg-stk17-c3 text-[52px] text-white shadow-[0_16px_32px_-16px_rgba(0,0,0,0.8)] animate-stk17-riffle [animation-delay:calc(var(--i)*-0.4s)] group-hover:[animation-play-state:paused] group-hover:[transform:translateX(calc((var(--i)-1.5)*54px))_rotate(calc((var(--i)-1.5)*6deg))] group-focus-visible:[animation-play-state:paused] group-focus-visible:[transform:translateX(calc((var(--i)-1.5)*54px))_rotate(calc((var(--i)-1.5)*6deg))] motion-reduce:animate-none motion-reduce:[transform:translateX(calc((var(--i)-1.5)*54px))_rotate(calc((var(--i)-1.5)*6deg))]" style="--i:2" aria-hidden="true">♥</div>
<div class="absolute inset-0 grid place-items-center rounded-2xl bg-stk17-c4 text-[52px] text-white shadow-[0_16px_32px_-16px_rgba(0,0,0,0.8)] animate-stk17-riffle [animation-delay:calc(var(--i)*-0.4s)] group-hover:[animation-play-state:paused] group-hover:[transform:translateX(calc((var(--i)-1.5)*54px))_rotate(calc((var(--i)-1.5)*6deg))] group-focus-visible:[animation-play-state:paused] group-focus-visible:[transform:translateX(calc((var(--i)-1.5)*54px))_rotate(calc((var(--i)-1.5)*6deg))] motion-reduce:animate-none motion-reduce:[transform:translateX(calc((var(--i)-1.5)*54px))_rotate(calc((var(--i)-1.5)*6deg))]" style="--i:3" aria-hidden="true">♠</div>
</div>
</div>
</section>
```
## CSS
```css
@import "tailwindcss";
@theme {
--color-stk17-bg: #131620;
--color-stk17-bg: oklch(0.21 0.018 275);
--color-stk17-ring: #45ab7c;
--color-stk17-ring: oklch(0.66 0.15 150);
--color-stk17-c1: #cf5f4b;
--color-stk17-c1: oklch(0.62 0.16 20);
--color-stk17-c2: #45ab7c;
--color-stk17-c2: oklch(0.64 0.15 150);
--color-stk17-c3: #4d8ade;
--color-stk17-c3: oklch(0.62 0.16 250);
--color-stk17-c4: #9b6ed6;
--color-stk17-c4: oklch(0.62 0.16 300);
--font-stk17-sans: 'Archivo', system-ui, sans-serif;
--animate-stk17-riffle: stk-17-riffle 1.6s ease-in-out infinite;
}
/* The riffle path itself. z-index is not interpolated inside keyframes — it
steps at each stop, which is exactly what a shuffle wants. */
@keyframes stk-17-riffle {
0%,
100% {
transform: translateX(0) rotate(0);
z-index: 2;
}
25% {
transform: translateX(70px) rotate(10deg);
z-index: 5;
}
50% {
transform: translateX(0) rotate(0);
z-index: 2;
}
75% {
transform: translateX(-70px) rotate(-10deg);
z-index: 1;
}
}
```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 Shuffle Cards Animation Loop Deck
Source: https://codefronts.com/components/tailwind-stacked-cards/tailwind-shuffle-cards-animation-loop-deck/
A deck riffling itself, offset per card by a negative animation-delay so the loop never syncs. Hover and it stops dead and fans out to be read — group-hover:[animation-play-state:paused] plus a static fan transform. The reduced-motion path is the interesting one: it does not slow the loop down, it removes it and ships the fanned deck as the resting state.
## HTML
```html
<section class="stk-17 w-full min-h-svh flex items-center justify-center px-5 py-15 font-stk17-sans bg-stk17-bg" aria-label="Shuffling card deck">
<div class="has-focus-visible:outline-3 has-focus-visible:outline-stk17-ring has-focus-visible:outline-offset-[12px] has-focus-visible:rounded-[22px]">
<div class="group relative w-37.5 h-50 outline-none" tabindex="0" role="img" aria-label="A deck of four suit cards, riffling. Hover or focus to pause and fan them out.">
<div class="absolute inset-0 grid place-items-center rounded-2xl bg-stk17-c1 text-[52px] text-white shadow-[0_16px_32px_-16px_rgba(0,0,0,0.8)] animate-stk17-riffle [animation-delay:calc(var(--i)*-0.4s)] group-hover:[animation-play-state:paused] group-hover:[transform:translateX(calc((var(--i)-1.5)*54px))_rotate(calc((var(--i)-1.5)*6deg))] group-focus-visible:[animation-play-state:paused] group-focus-visible:[transform:translateX(calc((var(--i)-1.5)*54px))_rotate(calc((var(--i)-1.5)*6deg))] motion-reduce:animate-none motion-reduce:[transform:translateX(calc((var(--i)-1.5)*54px))_rotate(calc((var(--i)-1.5)*6deg))]" style="--i:0" aria-hidden="true">♦</div>
<div class="absolute inset-0 grid place-items-center rounded-2xl bg-stk17-c2 text-[52px] text-white shadow-[0_16px_32px_-16px_rgba(0,0,0,0.8)] animate-stk17-riffle [animation-delay:calc(var(--i)*-0.4s)] group-hover:[animation-play-state:paused] group-hover:[transform:translateX(calc((var(--i)-1.5)*54px))_rotate(calc((var(--i)-1.5)*6deg))] group-focus-visible:[animation-play-state:paused] group-focus-visible:[transform:translateX(calc((var(--i)-1.5)*54px))_rotate(calc((var(--i)-1.5)*6deg))] motion-reduce:animate-none motion-reduce:[transform:translateX(calc((var(--i)-1.5)*54px))_rotate(calc((var(--i)-1.5)*6deg))]" style="--i:1" aria-hidden="true">♣</div>
<div class="absolute inset-0 grid place-items-center rounded-2xl bg-stk17-c3 text-[52px] text-white shadow-[0_16px_32px_-16px_rgba(0,0,0,0.8)] animate-stk17-riffle [animation-delay:calc(var(--i)*-0.4s)] group-hover:[animation-play-state:paused] group-hover:[transform:translateX(calc((var(--i)-1.5)*54px))_rotate(calc((var(--i)-1.5)*6deg))] group-focus-visible:[animation-play-state:paused] group-focus-visible:[transform:translateX(calc((var(--i)-1.5)*54px))_rotate(calc((var(--i)-1.5)*6deg))] motion-reduce:animate-none motion-reduce:[transform:translateX(calc((var(--i)-1.5)*54px))_rotate(calc((var(--i)-1.5)*6deg))]" style="--i:2" aria-hidden="true">♥</div>
<div class="absolute inset-0 grid place-items-center rounded-2xl bg-stk17-c4 text-[52px] text-white shadow-[0_16px_32px_-16px_rgba(0,0,0,0.8)] animate-stk17-riffle [animation-delay:calc(var(--i)*-0.4s)] group-hover:[animation-play-state:paused] group-hover:[transform:translateX(calc((var(--i)-1.5)*54px))_rotate(calc((var(--i)-1.5)*6deg))] group-focus-visible:[animation-play-state:paused] group-focus-visible:[transform:translateX(calc((var(--i)-1.5)*54px))_rotate(calc((var(--i)-1.5)*6deg))] motion-reduce:animate-none motion-reduce:[transform:translateX(calc((var(--i)-1.5)*54px))_rotate(calc((var(--i)-1.5)*6deg))]" style="--i:3" aria-hidden="true">♠</div>
</div>
</div>
</section>
```
## CSS
```css
@import "tailwindcss";
@theme {
--color-stk17-bg: #131620;
--color-stk17-bg: oklch(0.21 0.018 275);
--color-stk17-ring: #45ab7c;
--color-stk17-ring: oklch(0.66 0.15 150);
--color-stk17-c1: #cf5f4b;
--color-stk17-c1: oklch(0.62 0.16 20);
--color-stk17-c2: #45ab7c;
--color-stk17-c2: oklch(0.64 0.15 150);
--color-stk17-c3: #4d8ade;
--color-stk17-c3: oklch(0.62 0.16 250);
--color-stk17-c4: #9b6ed6;
--color-stk17-c4: oklch(0.62 0.16 300);
--font-stk17-sans: 'Archivo', system-ui, sans-serif;
--animate-stk17-riffle: stk-17-riffle 1.6s ease-in-out infinite;
}
/* The riffle path itself. z-index is not interpolated inside keyframes — it
steps at each stop, which is exactly what a shuffle wants. */
@keyframes stk-17-riffle {
0%,
100% {
transform: translateX(0) rotate(0);
z-index: 2;
}
25% {
transform: translateX(70px) rotate(10deg);
z-index: 5;
}
50% {
transform: translateX(0) rotate(0);
z-index: 2;
}
75% {
transform: translateX(-70px) rotate(-10deg);
z-index: 1;
}
}
```How this works
Negative delay is the whole trick. A positive delay staggers when each card starts; a negative one starts it already part-way through, which is what makes four cards on one keyframe look like a continuous riffle rather than a chorus line:
animate-stk17-riffle [animation-delay:calc(var(--i)*-0.4s)]Four :nth-child delay rules become one class reading the index. animation-delay does have a utility scale in v4, but not one that takes a calc() of a custom property, so the arbitrary form is the right call.
Pausing is not stopping. [animation-play-state:paused] freezes the keyframe where it is, and the static fan transform then overrides the frozen position — so hovering both stops the motion and lays the cards out flat. Removing the animation instead would snap the cards back to their unanimated positions first.
Decorative loops need a real off-switch. Continuous motion with no user intent behind it is the clearest case in the WCAG motion guidance. Under motion-reduce: the animation is none and the fan is applied unconditionally, so the deck arrives readable and completely still.
Make it yours
- Speed the riffle by editing the duration in
--animate-stk17-riffle. - Change the shuffle path in the
@keyframesblock — the one part that cannot be a utility. - Set the hover fan with the
54pxand6degmultipliers; the1.5centres it for four cards (use(n-1)/2for n cards). - Recolour the faces through the four
bg-stk17-*tokens. - Add a card by giving it the next
--iand updating the centring constant.
Gotchas — read before shipping
- The delay must be negative. A positive one staggers the starts and the deck looks like it is queueing, not riffling.
- Pause, do not remove.
animate-noneon hover snaps every card back to its resting transform before the fan applies. - Always ship the reduced-motion off-switch for a decorative loop — and ship the readable state, not a slower loop.
z-indexinside@keyframesis not interpolated; it steps at each keyframe, which is exactly what a riffle wants.- Keep the loop pausable by pointer and keyboard:
group-focus-visible:alongsidegroup-hover:. - The stage needs no fixed aspect, but it does need a height — the cards are absolutely positioned and contribute nothing to it.
Browser support
| Chrome | Safari | Firefox | Edge |
|---|---|---|---|
| 111+ | 16.4+ | 128+ | 111+ |
CSS keyframes, negative animation-delay and animation-play-state are universal (Chrome 49+, Safari 10+, Firefox 52+). The floor is Tailwind v4's own generated sheet.