24 / 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 Card Stack Tilt on Hover Quadrant
A deck that snaps to one of four poses depending on which quadrant your pointer is in — crisper and far cheaper than continuous tilt. The script writes two unitless numbers and CSS multiplies them into degrees, which is the pattern worth stealing: rotateX(calc(var(--ty,0)*1deg)) keeps the JS free of units and the CSS free of parsing.
Untitled UI Pro: 5,000+ React + Tailwind CSS v4 components · from $349 one-time
Untitled UI React · Affiliate link
The code
<section class="stk-24 w-full min-h-svh flex items-center justify-center px-5 py-15 font-stk24-sans bg-stk24-bg [background-image:radial-gradient(circle_at_50%_40%,var(--color-stk24-glow),transparent_70%)]" aria-label="Quadrant tilt card stack">
<div class="w-[min(300px,90vw)] [perspective:900px] has-focus-visible:[--tx:-9] has-focus-visible:[--ty:7]">
<div class="group relative h-55 outline-none [transform-style:preserve-3d] [transform:rotateX(calc(var(--ty,0)*1deg))_rotateY(calc(var(--tx,0)*1deg))] transition-transform duration-400 ease-out motion-reduce:transition-none" id="stk-24-stack" tabindex="0" role="group" aria-label="Tilting card deck">
<div class="absolute inset-0 rounded-[20px] border border-stk24-line bg-stk24-card shadow-[0_22px_44px_-26px_rgba(0,0,0,0.85)] [transform:translate(calc(var(--i)*8px),calc(var(--i)*8px))_translateZ(calc(var(--i)*-20px))]" style="--i:2" aria-hidden="true"></div>
<div class="absolute inset-0 rounded-[20px] border border-stk24-line bg-stk24-card shadow-[0_22px_44px_-26px_rgba(0,0,0,0.85)] [transform:translate(calc(var(--i)*8px),calc(var(--i)*8px))_translateZ(calc(var(--i)*-20px))]" style="--i:1" aria-hidden="true"></div>
<div class="absolute inset-0 overflow-hidden rounded-[20px] border border-stk24-line p-7 text-stk24-ink bg-linear-[150deg,var(--color-stk24-t1),var(--color-stk24-t2)] shadow-[0_22px_44px_-26px_rgba(0,0,0,0.85)] [transform:translate(calc(var(--i)*8px),calc(var(--i)*8px))_translateZ(calc(var(--i)*-20px))]" style="--i:0">
<p class="m-0 font-stk24-mono text-xs font-medium tracking-[0.16em] uppercase text-stk24-accent">Interactive</p>
<h3 class="mt-2.5 mb-2 text-3xl font-extrabold tracking-[-0.02em]">Tilt me</h3>
<p class="m-0 max-w-[26ch] text-sm leading-snug text-stk24-body">Enter from any corner — the deck banks toward you.</p>
<span class="absolute inset-0 pointer-events-none opacity-0 transition-opacity duration-300 group-hover:opacity-100 group-focus-visible:opacity-100 [background-image:radial-gradient(200px_circle_at_var(--mx,50%)_var(--my,50%),rgba(255,255,255,0.22),transparent_60%)]" id="stk-24-sheen" aria-hidden="true"></span>
</div>
</div>
</div>
</section><section class="stk-24 w-full min-h-svh flex items-center justify-center px-5 py-15 font-stk24-sans bg-stk24-bg [background-image:radial-gradient(circle_at_50%_40%,var(--color-stk24-glow),transparent_70%)]" aria-label="Quadrant tilt card stack">
<div class="w-[min(300px,90vw)] [perspective:900px] has-focus-visible:[--tx:-9] has-focus-visible:[--ty:7]">
<div class="group relative h-55 outline-none [transform-style:preserve-3d] [transform:rotateX(calc(var(--ty,0)*1deg))_rotateY(calc(var(--tx,0)*1deg))] transition-transform duration-400 ease-out motion-reduce:transition-none" id="stk-24-stack" tabindex="0" role="group" aria-label="Tilting card deck">
<div class="absolute inset-0 rounded-[20px] border border-stk24-line bg-stk24-card shadow-[0_22px_44px_-26px_rgba(0,0,0,0.85)] [transform:translate(calc(var(--i)*8px),calc(var(--i)*8px))_translateZ(calc(var(--i)*-20px))]" style="--i:2" aria-hidden="true"></div>
<div class="absolute inset-0 rounded-[20px] border border-stk24-line bg-stk24-card shadow-[0_22px_44px_-26px_rgba(0,0,0,0.85)] [transform:translate(calc(var(--i)*8px),calc(var(--i)*8px))_translateZ(calc(var(--i)*-20px))]" style="--i:1" aria-hidden="true"></div>
<div class="absolute inset-0 overflow-hidden rounded-[20px] border border-stk24-line p-7 text-stk24-ink bg-linear-[150deg,var(--color-stk24-t1),var(--color-stk24-t2)] shadow-[0_22px_44px_-26px_rgba(0,0,0,0.85)] [transform:translate(calc(var(--i)*8px),calc(var(--i)*8px))_translateZ(calc(var(--i)*-20px))]" style="--i:0">
<p class="m-0 font-stk24-mono text-xs font-medium tracking-[0.16em] uppercase text-stk24-accent">Interactive</p>
<h3 class="mt-2.5 mb-2 text-3xl font-extrabold tracking-[-0.02em]">Tilt me</h3>
<p class="m-0 max-w-[26ch] text-sm leading-snug text-stk24-body">Enter from any corner — the deck banks toward you.</p>
<span class="absolute inset-0 pointer-events-none opacity-0 transition-opacity duration-300 group-hover:opacity-100 group-focus-visible:opacity-100 [background-image:radial-gradient(200px_circle_at_var(--mx,50%)_var(--my,50%),rgba(255,255,255,0.22),transparent_60%)]" id="stk-24-sheen" aria-hidden="true"></span>
</div>
</div>
</div>
</section>@import "tailwindcss";
@theme {
--color-stk24-bg: #080d12;
--color-stk24-bg: oklch(0.13 0.014 220);
--color-stk24-glow: #10222a;
--color-stk24-glow: oklch(0.24 0.030 215);
--color-stk24-card: #132831;
--color-stk24-card: oklch(0.27 0.030 215);
--color-stk24-line: #1e3b46;
--color-stk24-line: oklch(0.34 0.035 215);
--color-stk24-t1: #153039;
--color-stk24-t1: oklch(0.29 0.032 215);
--color-stk24-t2: #0e2028;
--color-stk24-t2: oklch(0.22 0.028 215);
--color-stk24-ink: #eaf6f8;
--color-stk24-ink: oklch(0.96 0.012 215);
--color-stk24-body: #a9c6cd;
--color-stk24-body: oklch(0.80 0.028 215);
--color-stk24-accent: #4fd0dc;
--color-stk24-accent: oklch(0.80 0.13 195);
--font-stk24-sans: 'Archivo', system-ui, sans-serif;
--font-stk24-mono: 'JetBrains Mono', ui-monospace, monospace;
}@import "tailwindcss";
@theme {
--color-stk24-bg: #080d12;
--color-stk24-bg: oklch(0.13 0.014 220);
--color-stk24-glow: #10222a;
--color-stk24-glow: oklch(0.24 0.030 215);
--color-stk24-card: #132831;
--color-stk24-card: oklch(0.27 0.030 215);
--color-stk24-line: #1e3b46;
--color-stk24-line: oklch(0.34 0.035 215);
--color-stk24-t1: #153039;
--color-stk24-t1: oklch(0.29 0.032 215);
--color-stk24-t2: #0e2028;
--color-stk24-t2: oklch(0.22 0.028 215);
--color-stk24-ink: #eaf6f8;
--color-stk24-ink: oklch(0.96 0.012 215);
--color-stk24-body: #a9c6cd;
--color-stk24-body: oklch(0.80 0.028 215);
--color-stk24-accent: #4fd0dc;
--color-stk24-accent: oklch(0.80 0.13 195);
--font-stk24-sans: 'Archivo', system-ui, sans-serif;
--font-stk24-mono: 'JetBrains Mono', ui-monospace, monospace;
}(() => {
const root = document.querySelector('.stk-24');
if (!root) return;
const stack = root.querySelector('#stk-24-stack');
const sheen = root.querySelector('#stk-24-sheen');
const MAX = 12;
/* Unitless numbers only. CSS multiplies them into degrees, so this handler
never concatenates units and the same values could drive anything else. */
stack.addEventListener('pointermove', (e) => {
const r = stack.getBoundingClientRect();
const px = (e.clientX - r.left) / r.width;
const py = (e.clientY - r.top) / r.height;
stack.style.setProperty('--tx', String((px < 0.5 ? -1 : 1) * MAX));
stack.style.setProperty('--ty', String((py < 0.5 ? 1 : -1) * MAX));
sheen.style.setProperty('--mx', (px * 100).toFixed(1) + '%');
sheen.style.setProperty('--my', (py * 100).toFixed(1) + '%');
});
stack.addEventListener('pointerleave', () => {
stack.style.setProperty('--tx', '0');
stack.style.setProperty('--ty', '0');
});
})();(() => {
const root = document.querySelector('.stk-24');
if (!root) return;
const stack = root.querySelector('#stk-24-stack');
const sheen = root.querySelector('#stk-24-sheen');
const MAX = 12;
/* Unitless numbers only. CSS multiplies them into degrees, so this handler
never concatenates units and the same values could drive anything else. */
stack.addEventListener('pointermove', (e) => {
const r = stack.getBoundingClientRect();
const px = (e.clientX - r.left) / r.width;
const py = (e.clientY - r.top) / r.height;
stack.style.setProperty('--tx', String((px < 0.5 ? -1 : 1) * MAX));
stack.style.setProperty('--ty', String((py < 0.5 ? 1 : -1) * MAX));
sheen.style.setProperty('--mx', (px * 100).toFixed(1) + '%');
sheen.style.setProperty('--my', (py * 100).toFixed(1) + '%');
});
stack.addEventListener('pointerleave', () => {
stack.style.setProperty('--tx', '0');
stack.style.setProperty('--ty', '0');
});
})();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-stk24-bg: #080d12;
--color-stk24-bg: oklch(0.13 0.014 220);
--color-stk24-glow: #10222a;
--color-stk24-glow: oklch(0.24 0.030 215);
--color-stk24-card: #132831;
--color-stk24-card: oklch(0.27 0.030 215);
--color-stk24-line: #1e3b46;
--color-stk24-line: oklch(0.34 0.035 215);
--color-stk24-t1: #153039;
--color-stk24-t1: oklch(0.29 0.032 215);
--color-stk24-t2: #0e2028;
--color-stk24-t2: oklch(0.22 0.028 215);
--color-stk24-ink: #eaf6f8;
--color-stk24-ink: oklch(0.96 0.012 215);
--color-stk24-body: #a9c6cd;
--color-stk24-body: oklch(0.80 0.028 215);
--color-stk24-accent: #4fd0dc;
--color-stk24-accent: oklch(0.80 0.13 195);
--font-stk24-sans: 'Archivo', system-ui, sans-serif;
--font-stk24-mono: 'JetBrains Mono', ui-monospace, monospace;
}@import "tailwindcss";
@theme {
--color-stk24-bg: #080d12;
--color-stk24-bg: oklch(0.13 0.014 220);
--color-stk24-glow: #10222a;
--color-stk24-glow: oklch(0.24 0.030 215);
--color-stk24-card: #132831;
--color-stk24-card: oklch(0.27 0.030 215);
--color-stk24-line: #1e3b46;
--color-stk24-line: oklch(0.34 0.035 215);
--color-stk24-t1: #153039;
--color-stk24-t1: oklch(0.29 0.032 215);
--color-stk24-t2: #0e2028;
--color-stk24-t2: oklch(0.22 0.028 215);
--color-stk24-ink: #eaf6f8;
--color-stk24-ink: oklch(0.96 0.012 215);
--color-stk24-body: #a9c6cd;
--color-stk24-body: oklch(0.80 0.028 215);
--color-stk24-accent: #4fd0dc;
--color-stk24-accent: oklch(0.80 0.13 195);
--font-stk24-sans: 'Archivo', system-ui, sans-serif;
--font-stk24-mono: 'JetBrains Mono', ui-monospace, monospace;
}<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,700;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,700;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. - The handler writes unitless numbers and the utility multiplies them into degrees —
calc(var(--ty,0)*1deg)— which keeps units out of the JavaScript entirely. - The
var(--tx,0)fallbacks live in the class list, so the deck renders flat and correct before the script has run. - Quadrant snapping is a better interaction as well as a cheaper one: four stable poses, no jitter at the midlines.
- The keyboard pose moves from a
:has()rule tohas-focus-visible:[--tx:-9]on the scene — a variant setting a custom property, which is unusual and exactly right here. - The sheen is one span with an arbitrary
radial-gradientandpointer-events-none; the CSS version needs a rule plus a second rule to reveal it. - Depth on the under-cards combines a 2D offset and a
translateZfrom the same index, so the pile has thickness in the tilt rather than just an offset.
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 Card Stack Tilt on Hover Quadrant
Source: https://codefronts.com/components/tailwind-stacked-cards/tailwind-card-stack-tilt-on-hover-quadrant/
A deck that snaps to one of four poses depending on which quadrant your pointer is in — crisper and far cheaper than continuous tilt. The script writes two unitless numbers and CSS multiplies them into degrees, which is the pattern worth stealing: rotateX(calc(var(--ty,0)*1deg)) keeps the JS free of units and the CSS free of parsing.
## HTML
```html
<section class="stk-24 w-full min-h-svh flex items-center justify-center px-5 py-15 font-stk24-sans bg-stk24-bg [background-image:radial-gradient(circle_at_50%_40%,var(--color-stk24-glow),transparent_70%)]" aria-label="Quadrant tilt card stack">
<div class="w-[min(300px,90vw)] [perspective:900px] has-focus-visible:[--tx:-9] has-focus-visible:[--ty:7]">
<div class="group relative h-55 outline-none [transform-style:preserve-3d] [transform:rotateX(calc(var(--ty,0)*1deg))_rotateY(calc(var(--tx,0)*1deg))] transition-transform duration-400 ease-out motion-reduce:transition-none" id="stk-24-stack" tabindex="0" role="group" aria-label="Tilting card deck">
<div class="absolute inset-0 rounded-[20px] border border-stk24-line bg-stk24-card shadow-[0_22px_44px_-26px_rgba(0,0,0,0.85)] [transform:translate(calc(var(--i)*8px),calc(var(--i)*8px))_translateZ(calc(var(--i)*-20px))]" style="--i:2" aria-hidden="true"></div>
<div class="absolute inset-0 rounded-[20px] border border-stk24-line bg-stk24-card shadow-[0_22px_44px_-26px_rgba(0,0,0,0.85)] [transform:translate(calc(var(--i)*8px),calc(var(--i)*8px))_translateZ(calc(var(--i)*-20px))]" style="--i:1" aria-hidden="true"></div>
<div class="absolute inset-0 overflow-hidden rounded-[20px] border border-stk24-line p-7 text-stk24-ink bg-linear-[150deg,var(--color-stk24-t1),var(--color-stk24-t2)] shadow-[0_22px_44px_-26px_rgba(0,0,0,0.85)] [transform:translate(calc(var(--i)*8px),calc(var(--i)*8px))_translateZ(calc(var(--i)*-20px))]" style="--i:0">
<p class="m-0 font-stk24-mono text-xs font-medium tracking-[0.16em] uppercase text-stk24-accent">Interactive</p>
<h3 class="mt-2.5 mb-2 text-3xl font-extrabold tracking-[-0.02em]">Tilt me</h3>
<p class="m-0 max-w-[26ch] text-sm leading-snug text-stk24-body">Enter from any corner — the deck banks toward you.</p>
<span class="absolute inset-0 pointer-events-none opacity-0 transition-opacity duration-300 group-hover:opacity-100 group-focus-visible:opacity-100 [background-image:radial-gradient(200px_circle_at_var(--mx,50%)_var(--my,50%),rgba(255,255,255,0.22),transparent_60%)]" id="stk-24-sheen" aria-hidden="true"></span>
</div>
</div>
</div>
</section>
```
## CSS
```css
@import "tailwindcss";
@theme {
--color-stk24-bg: #080d12;
--color-stk24-bg: oklch(0.13 0.014 220);
--color-stk24-glow: #10222a;
--color-stk24-glow: oklch(0.24 0.030 215);
--color-stk24-card: #132831;
--color-stk24-card: oklch(0.27 0.030 215);
--color-stk24-line: #1e3b46;
--color-stk24-line: oklch(0.34 0.035 215);
--color-stk24-t1: #153039;
--color-stk24-t1: oklch(0.29 0.032 215);
--color-stk24-t2: #0e2028;
--color-stk24-t2: oklch(0.22 0.028 215);
--color-stk24-ink: #eaf6f8;
--color-stk24-ink: oklch(0.96 0.012 215);
--color-stk24-body: #a9c6cd;
--color-stk24-body: oklch(0.80 0.028 215);
--color-stk24-accent: #4fd0dc;
--color-stk24-accent: oklch(0.80 0.13 195);
--font-stk24-sans: 'Archivo', system-ui, sans-serif;
--font-stk24-mono: 'JetBrains Mono', ui-monospace, monospace;
}
```
## JavaScript
```js
(() => {
const root = document.querySelector('.stk-24');
if (!root) return;
const stack = root.querySelector('#stk-24-stack');
const sheen = root.querySelector('#stk-24-sheen');
const MAX = 12;
/* Unitless numbers only. CSS multiplies them into degrees, so this handler
never concatenates units and the same values could drive anything else. */
stack.addEventListener('pointermove', (e) => {
const r = stack.getBoundingClientRect();
const px = (e.clientX - r.left) / r.width;
const py = (e.clientY - r.top) / r.height;
stack.style.setProperty('--tx', String((px < 0.5 ? -1 : 1) * MAX));
stack.style.setProperty('--ty', String((py < 0.5 ? 1 : -1) * MAX));
sheen.style.setProperty('--mx', (px * 100).toFixed(1) + '%');
sheen.style.setProperty('--my', (py * 100).toFixed(1) + '%');
});
stack.addEventListener('pointerleave', () => {
stack.style.setProperty('--tx', '0');
stack.style.setProperty('--ty', '0');
});
})();
```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 Card Stack Tilt on Hover Quadrant
Source: https://codefronts.com/components/tailwind-stacked-cards/tailwind-card-stack-tilt-on-hover-quadrant/
A deck that snaps to one of four poses depending on which quadrant your pointer is in — crisper and far cheaper than continuous tilt. The script writes two unitless numbers and CSS multiplies them into degrees, which is the pattern worth stealing: rotateX(calc(var(--ty,0)*1deg)) keeps the JS free of units and the CSS free of parsing.
## HTML
```html
<section class="stk-24 w-full min-h-svh flex items-center justify-center px-5 py-15 font-stk24-sans bg-stk24-bg [background-image:radial-gradient(circle_at_50%_40%,var(--color-stk24-glow),transparent_70%)]" aria-label="Quadrant tilt card stack">
<div class="w-[min(300px,90vw)] [perspective:900px] has-focus-visible:[--tx:-9] has-focus-visible:[--ty:7]">
<div class="group relative h-55 outline-none [transform-style:preserve-3d] [transform:rotateX(calc(var(--ty,0)*1deg))_rotateY(calc(var(--tx,0)*1deg))] transition-transform duration-400 ease-out motion-reduce:transition-none" id="stk-24-stack" tabindex="0" role="group" aria-label="Tilting card deck">
<div class="absolute inset-0 rounded-[20px] border border-stk24-line bg-stk24-card shadow-[0_22px_44px_-26px_rgba(0,0,0,0.85)] [transform:translate(calc(var(--i)*8px),calc(var(--i)*8px))_translateZ(calc(var(--i)*-20px))]" style="--i:2" aria-hidden="true"></div>
<div class="absolute inset-0 rounded-[20px] border border-stk24-line bg-stk24-card shadow-[0_22px_44px_-26px_rgba(0,0,0,0.85)] [transform:translate(calc(var(--i)*8px),calc(var(--i)*8px))_translateZ(calc(var(--i)*-20px))]" style="--i:1" aria-hidden="true"></div>
<div class="absolute inset-0 overflow-hidden rounded-[20px] border border-stk24-line p-7 text-stk24-ink bg-linear-[150deg,var(--color-stk24-t1),var(--color-stk24-t2)] shadow-[0_22px_44px_-26px_rgba(0,0,0,0.85)] [transform:translate(calc(var(--i)*8px),calc(var(--i)*8px))_translateZ(calc(var(--i)*-20px))]" style="--i:0">
<p class="m-0 font-stk24-mono text-xs font-medium tracking-[0.16em] uppercase text-stk24-accent">Interactive</p>
<h3 class="mt-2.5 mb-2 text-3xl font-extrabold tracking-[-0.02em]">Tilt me</h3>
<p class="m-0 max-w-[26ch] text-sm leading-snug text-stk24-body">Enter from any corner — the deck banks toward you.</p>
<span class="absolute inset-0 pointer-events-none opacity-0 transition-opacity duration-300 group-hover:opacity-100 group-focus-visible:opacity-100 [background-image:radial-gradient(200px_circle_at_var(--mx,50%)_var(--my,50%),rgba(255,255,255,0.22),transparent_60%)]" id="stk-24-sheen" aria-hidden="true"></span>
</div>
</div>
</div>
</section>
```
## CSS
```css
@import "tailwindcss";
@theme {
--color-stk24-bg: #080d12;
--color-stk24-bg: oklch(0.13 0.014 220);
--color-stk24-glow: #10222a;
--color-stk24-glow: oklch(0.24 0.030 215);
--color-stk24-card: #132831;
--color-stk24-card: oklch(0.27 0.030 215);
--color-stk24-line: #1e3b46;
--color-stk24-line: oklch(0.34 0.035 215);
--color-stk24-t1: #153039;
--color-stk24-t1: oklch(0.29 0.032 215);
--color-stk24-t2: #0e2028;
--color-stk24-t2: oklch(0.22 0.028 215);
--color-stk24-ink: #eaf6f8;
--color-stk24-ink: oklch(0.96 0.012 215);
--color-stk24-body: #a9c6cd;
--color-stk24-body: oklch(0.80 0.028 215);
--color-stk24-accent: #4fd0dc;
--color-stk24-accent: oklch(0.80 0.13 195);
--font-stk24-sans: 'Archivo', system-ui, sans-serif;
--font-stk24-mono: 'JetBrains Mono', ui-monospace, monospace;
}
```
## JavaScript
```js
(() => {
const root = document.querySelector('.stk-24');
if (!root) return;
const stack = root.querySelector('#stk-24-stack');
const sheen = root.querySelector('#stk-24-sheen');
const MAX = 12;
/* Unitless numbers only. CSS multiplies them into degrees, so this handler
never concatenates units and the same values could drive anything else. */
stack.addEventListener('pointermove', (e) => {
const r = stack.getBoundingClientRect();
const px = (e.clientX - r.left) / r.width;
const py = (e.clientY - r.top) / r.height;
stack.style.setProperty('--tx', String((px < 0.5 ? -1 : 1) * MAX));
stack.style.setProperty('--ty', String((py < 0.5 ? 1 : -1) * MAX));
sheen.style.setProperty('--mx', (px * 100).toFixed(1) + '%');
sheen.style.setProperty('--my', (py * 100).toFixed(1) + '%');
});
stack.addEventListener('pointerleave', () => {
stack.style.setProperty('--tx', '0');
stack.style.setProperty('--ty', '0');
});
})();
```How this works
Let CSS own the units. The handler writes --tx and --ty as bare numbers and the transform gives them meaning:
[transform:rotateX(calc(var(--ty,0)*1deg))_rotateY(calc(var(--tx,0)*1deg))]Two benefits, both real: the script never does string concatenation with units, and the same numbers could later drive a translate or a shadow without being re-parsed. The fallbacks in var(--ty,0) mean the deck renders flat before the script runs.
Quadrants, not continuous tracking. Rounding the pointer position to a sign per axis gives four poses. It costs one comparison instead of a smooth interpolation, it never jitters at the edges, and it reads as deliberate rather than clingy — a genuinely better interaction, not just a cheaper one.
The sheen is the only continuous part. --mx/--my track the pointer as percentages and feed one arbitrary radial-gradient. Because it is a background and not a layout property, it stays on the compositor and does not fight the snapping tilt.
Keyboards get a pose, not nothing. has-focus-visible: on the scene applies a fixed flattering tilt, so tabbing to the deck shows the same 3D affordance a pointer user gets.
Make it yours
- Change the tilt ceiling with the
MAXconstant — 12deg is deliberate; past about 18deg the text starts to distort. - Switch to continuous tilt by removing the sign rounding in the handler; the CSS needs no change at all.
- Restyle or drop the sheen — it is one element and one background.
- Recolour through
--color-stk24-accent. - Change the pile's thickness with the
8pxoffsets and the-20pxtranslateZstep.
Gotchas — read before shipping
- Write custom properties in the pointer handler, never read geometry there. A
getBoundingClientRect()perpointermoveforces layout and shows up in INP. - Reset on
pointerleave, or the tilt sticks at whatever pose the pointer left behind. [perspective:…]belongs on the scene, not on the tilting stack.- Include the
var(--tx,0)fallbacks so the deck renders flat rather than broken before the script runs. - The sheen must be
pointer-events-noneor it swallows the hover it exists to visualise. - Cap the tilt. Beyond about 18deg the text on the top card is visibly distorted and harder to read.
Browser support
| Chrome | Safari | Firefox | Edge |
|---|---|---|---|
| 111+ | 16.4+ | 128+ | 111+ |
3D transforms and custom properties in calc() are broadly supported (Chrome 36+, Safari 9+, Firefox 16+). The floor is Tailwind v4's own generated sheet.