12 / 15
All 15 in 15 Tailwind CSS 3D Tilt Hover Cards
- 01Tailwind 3D Tilt Product Card with Parallax Hover Effect
- 02Tailwind Glassmorphism 3D Tilt Profile Card
- 03Tailwind 3D Tilt Pricing Cards with Monthly / Annual Toggle
- 04Tailwind Holographic Card Effect with 3D Tilt
- 05Tailwind 3D Pop-Out Character Card with Cursor-Tracking Eyes
- 06Tailwind Mouse-Following Border Glow Cards
- 07Tailwind 3D Tilt Music Player Card with Spring Physics
- 08Tailwind Blog Card Hover Effect with Subtle 3D Tilt
- 09Tailwind Cyberpunk Neon Glow 3D Tilt Card
- 10Tailwind Dashboard KPI Card with 3D Hover Lift
- 11Tailwind Real Estate Listing Card with Grayscale-to-Colour Hover
- 12Tailwind 3D Flip Card with Mouse Tilt (Click to Flip)
- 13Tailwind 3D Floating Phone Mockup Card for App Landing Pages
- 14Tailwind Course Progress Card with 3D Tilt
- 15Pure Tailwind 3D Tilt Card — No JavaScript (:has() Hover Zones)
Tailwind 3D Flip Card with Mouse Tilt (Click to Flip)
Tilt and flip in one transform: the card follows the cursor, and a click (or Enter/Space) swings it 180° to reveal contact details — and it keeps tilting while flipped. Flip and tilt compose in one rotateY(calc(var(--flip)_+_var(--ry))). The port fixes a real bug: the hidden face is now inert, so its buttons can no longer be tabbed to while invisible.
Untitled UI Pro: 5,000+ React + Tailwind CSS v4 components · from $349 one-time
Untitled UI React · Affiliate link
The code
<section class="ttc-12 w-full min-h-svh grid place-items-center bg-ttc12-bg bg-[radial-gradient(80%_90%_at_70%_20%,var(--color-ttc12-lobe),transparent_60%)] px-6 py-14 font-ttc12-sans text-white" aria-label="3D flip card with mouse tilt demo">
<div class="grid w-full justify-items-center gap-8">
<div data-ttc12-scene class="w-full max-w-[19.5rem] perspective-[1100px]">
<div data-carrier class="relative h-60 transform-3d [transform:rotateX(var(--rx,0deg))_rotateY(calc(var(--flip,0deg)_+_var(--ry,0deg)))] transition-transform duration-700 ease-[cubic-bezier(0.3,0.8,0.3,1.1)] data-live:duration-90 data-live:ease-linear motion-reduce:transition-none">
<article data-face="front" class="absolute inset-0 flex flex-col rounded-[20px] border border-ttc12-acc/35 bg-linear-160 from-ttc12-card1 to-ttc12-card2 p-6 shadow-[0_26px_54px_-26px_oklch(0_0_0/0.8)] backface-hidden">
<p class="font-ttc12-mono text-[10.5px] font-bold tracking-[0.2em] text-ttc12-acc">FREELANCE · SF</p>
<h3 class="mt-3 text-[28px] leading-none font-bold tracking-[-0.02em]">Jonah Reyes</h3>
<p class="mt-1.5 text-sm text-ttc12-mut">Motion & Brand Designer</p>
<button data-flip type="button" aria-pressed="false" class="mt-auto inline-flex min-h-11 cursor-pointer items-center gap-2 self-start rounded-full bg-ttc12-acc px-5 text-[13px] font-semibold text-ttc12-bg transition-transform hover:scale-105 focus-visible:outline-3 focus-visible:outline-offset-3 focus-visible:outline-white">Contact <span aria-hidden="true">↻</span></button>
</article>
<article data-face="back" inert class="absolute inset-0 flex flex-col rounded-[20px] border border-ttc12-acc/35 bg-linear-160 from-ttc12-card1 to-ttc12-card2 p-6 shadow-[0_26px_54px_-26px_oklch(0_0_0/0.8)] backface-hidden [transform:rotateY(180deg)]">
<p class="font-ttc12-mono text-[10.5px] font-bold tracking-[0.2em] text-ttc12-acc">SAY HELLO</p>
<ul class="mt-2 grid text-[14px]">
<li><a href="mailto:jonah@studioreyes.co" class="flex min-h-11 items-center gap-3 rounded-lg text-white no-underline hover:text-ttc12-acc focus-visible:outline-2 focus-visible:outline-ttc12-acc"><svg aria-hidden="true" viewBox="0 0 24 24" class="size-4 flex-none text-ttc12-acc" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="5" width="18" height="14" rx="2"/><path d="M3 7l9 6 9-6"/></svg>jonah@studioreyes.co</a></li>
<li><a href="tel:+14155550132" class="flex min-h-11 items-center gap-3 rounded-lg text-white no-underline hover:text-ttc12-acc focus-visible:outline-2 focus-visible:outline-ttc12-acc"><svg aria-hidden="true" viewBox="0 0 24 24" class="size-4 flex-none text-ttc12-acc" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"><path d="M5 3h4l2 5-2.5 1.5a11 11 0 0 0 6 6L16 13l5 2v4a2 2 0 0 1-2 2A16 16 0 0 1 3 5a2 2 0 0 1 2-2z"/></svg>+1 (415) 555-0132</a></li>
</ul>
<button data-flip type="button" aria-pressed="true" class="mt-auto inline-flex min-h-11 cursor-pointer items-center gap-2 self-start rounded-full border border-white/25 px-5 text-[13px] font-semibold text-white transition-colors hover:bg-white/10 focus-visible:outline-3 focus-visible:outline-offset-3 focus-visible:outline-white"><span aria-hidden="true">↺</span> Back</button>
</article>
</div>
</div>
<p aria-hidden="true" class="rounded-full border border-white/12 bg-white/5 px-3.5 py-2 font-ttc12-mono text-[11.5px] text-ttc12-mut">rotateY(calc(var(--flip)_+_var(--ry))) · backface-hidden · inert</p>
</div>
</section><section class="ttc-12 w-full min-h-svh grid place-items-center bg-ttc12-bg bg-[radial-gradient(80%_90%_at_70%_20%,var(--color-ttc12-lobe),transparent_60%)] px-6 py-14 font-ttc12-sans text-white" aria-label="3D flip card with mouse tilt demo">
<div class="grid w-full justify-items-center gap-8">
<div data-ttc12-scene class="w-full max-w-[19.5rem] perspective-[1100px]">
<div data-carrier class="relative h-60 transform-3d [transform:rotateX(var(--rx,0deg))_rotateY(calc(var(--flip,0deg)_+_var(--ry,0deg)))] transition-transform duration-700 ease-[cubic-bezier(0.3,0.8,0.3,1.1)] data-live:duration-90 data-live:ease-linear motion-reduce:transition-none">
<article data-face="front" class="absolute inset-0 flex flex-col rounded-[20px] border border-ttc12-acc/35 bg-linear-160 from-ttc12-card1 to-ttc12-card2 p-6 shadow-[0_26px_54px_-26px_oklch(0_0_0/0.8)] backface-hidden">
<p class="font-ttc12-mono text-[10.5px] font-bold tracking-[0.2em] text-ttc12-acc">FREELANCE · SF</p>
<h3 class="mt-3 text-[28px] leading-none font-bold tracking-[-0.02em]">Jonah Reyes</h3>
<p class="mt-1.5 text-sm text-ttc12-mut">Motion & Brand Designer</p>
<button data-flip type="button" aria-pressed="false" class="mt-auto inline-flex min-h-11 cursor-pointer items-center gap-2 self-start rounded-full bg-ttc12-acc px-5 text-[13px] font-semibold text-ttc12-bg transition-transform hover:scale-105 focus-visible:outline-3 focus-visible:outline-offset-3 focus-visible:outline-white">Contact <span aria-hidden="true">↻</span></button>
</article>
<article data-face="back" inert class="absolute inset-0 flex flex-col rounded-[20px] border border-ttc12-acc/35 bg-linear-160 from-ttc12-card1 to-ttc12-card2 p-6 shadow-[0_26px_54px_-26px_oklch(0_0_0/0.8)] backface-hidden [transform:rotateY(180deg)]">
<p class="font-ttc12-mono text-[10.5px] font-bold tracking-[0.2em] text-ttc12-acc">SAY HELLO</p>
<ul class="mt-2 grid text-[14px]">
<li><a href="mailto:jonah@studioreyes.co" class="flex min-h-11 items-center gap-3 rounded-lg text-white no-underline hover:text-ttc12-acc focus-visible:outline-2 focus-visible:outline-ttc12-acc"><svg aria-hidden="true" viewBox="0 0 24 24" class="size-4 flex-none text-ttc12-acc" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="5" width="18" height="14" rx="2"/><path d="M3 7l9 6 9-6"/></svg>jonah@studioreyes.co</a></li>
<li><a href="tel:+14155550132" class="flex min-h-11 items-center gap-3 rounded-lg text-white no-underline hover:text-ttc12-acc focus-visible:outline-2 focus-visible:outline-ttc12-acc"><svg aria-hidden="true" viewBox="0 0 24 24" class="size-4 flex-none text-ttc12-acc" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"><path d="M5 3h4l2 5-2.5 1.5a11 11 0 0 0 6 6L16 13l5 2v4a2 2 0 0 1-2 2A16 16 0 0 1 3 5a2 2 0 0 1 2-2z"/></svg>+1 (415) 555-0132</a></li>
</ul>
<button data-flip type="button" aria-pressed="true" class="mt-auto inline-flex min-h-11 cursor-pointer items-center gap-2 self-start rounded-full border border-white/25 px-5 text-[13px] font-semibold text-white transition-colors hover:bg-white/10 focus-visible:outline-3 focus-visible:outline-offset-3 focus-visible:outline-white"><span aria-hidden="true">↺</span> Back</button>
</article>
</div>
</div>
<p aria-hidden="true" class="rounded-full border border-white/12 bg-white/5 px-3.5 py-2 font-ttc12-mono text-[11.5px] text-ttc12-mut">rotateY(calc(var(--flip)_+_var(--ry))) · backface-hidden · inert</p>
</div>
</section>@import "tailwindcss";
@theme {
--color-ttc12-bg: #0e0d15;
--color-ttc12-bg: oklch(0.15 0.015 270);
--color-ttc12-lobe: #3b2f14b3;
--color-ttc12-lobe: oklch(0.25 0.05 80 / 0.7);
--color-ttc12-card1: #2a2517;
--color-ttc12-card1: oklch(0.23 0.03 80);
--color-ttc12-card2: #16151d;
--color-ttc12-card2: oklch(0.17 0.02 270);
--color-ttc12-acc: #e8b94a;
--color-ttc12-acc: oklch(0.8 0.14 80);
--color-ttc12-mut: #c6c3cf;
--color-ttc12-mut: oklch(0.82 0.015 280);
--font-ttc12-sans: 'Space Grotesk', system-ui, sans-serif;
--font-ttc12-mono: 'JetBrains Mono', ui-monospace, monospace;
}
/* Root rule: the full-bleed contract, stated in CSS as well as in the markup's utilities, so the
stage is sized before the browser build compiles. */
.ttc-12 {
width: 100%;
min-height: 100svh;
}@import "tailwindcss";
@theme {
--color-ttc12-bg: #0e0d15;
--color-ttc12-bg: oklch(0.15 0.015 270);
--color-ttc12-lobe: #3b2f14b3;
--color-ttc12-lobe: oklch(0.25 0.05 80 / 0.7);
--color-ttc12-card1: #2a2517;
--color-ttc12-card1: oklch(0.23 0.03 80);
--color-ttc12-card2: #16151d;
--color-ttc12-card2: oklch(0.17 0.02 270);
--color-ttc12-acc: #e8b94a;
--color-ttc12-acc: oklch(0.8 0.14 80);
--color-ttc12-mut: #c6c3cf;
--color-ttc12-mut: oklch(0.82 0.015 280);
--font-ttc12-sans: 'Space Grotesk', system-ui, sans-serif;
--font-ttc12-mono: 'JetBrains Mono', ui-monospace, monospace;
}
/* Root rule: the full-bleed contract, stated in CSS as well as in the markup's utilities, so the
stage is sized before the browser build compiles. */
.ttc-12 {
width: 100%;
min-height: 100svh;
}(() => {
document.querySelectorAll('[data-ttc12-scene]').forEach((scene) => {
if (scene.dataset.wired) return;
scene.dataset.wired = '1';
const carrier = scene.querySelector('[data-carrier]');
const front = carrier.querySelector('[data-face="front"]');
const back = carrier.querySelector('[data-face="back"]');
const btns = carrier.querySelectorAll('[data-flip]');
let flipped = false, swinging = 0;
const setFlip = (f) => {
flipped = f;
carrier.style.setProperty('--flip', f ? '180deg' : '0deg');
front.inert = f; // hidden face leaves the tab order AND the a11y tree
back.inert = !f;
btns.forEach((b) => b.setAttribute('aria-pressed', String(f)));
carrier.removeAttribute('data-live'); // fall back to the slow 700ms swing
clearTimeout(swinging);
swinging = setTimeout(() => { swinging = 0; }, 700);
(f ? back : front).querySelector('[data-flip]').focus({ preventScroll: true });
};
btns.forEach((b) => b.addEventListener('click', () => setFlip(!flipped)));
if (!matchMedia('(hover:hover) and (pointer:fine)').matches) return;
if (matchMedia('(prefers-reduced-motion: reduce)').matches) return;
const MAX = 10;
scene.addEventListener('pointermove', (e) => {
if (swinging) return; // let the swing finish clean
const r = scene.getBoundingClientRect();
const px = (e.clientX - r.left) / r.width, py = (e.clientY - r.top) / r.height;
const dir = flipped ? -1 : 1; // pointer-left is back-face-right
carrier.style.setProperty('--rx', ((0.5 - py) * MAX).toFixed(2) + 'deg');
carrier.style.setProperty('--ry', (dir * (px - 0.5) * MAX).toFixed(2) + 'deg');
carrier.setAttribute('data-live', '');
});
scene.addEventListener('pointerleave', () => {
carrier.removeAttribute('data-live');
carrier.style.setProperty('--rx', '0deg');
carrier.style.setProperty('--ry', '0deg');
});
});
})();(() => {
document.querySelectorAll('[data-ttc12-scene]').forEach((scene) => {
if (scene.dataset.wired) return;
scene.dataset.wired = '1';
const carrier = scene.querySelector('[data-carrier]');
const front = carrier.querySelector('[data-face="front"]');
const back = carrier.querySelector('[data-face="back"]');
const btns = carrier.querySelectorAll('[data-flip]');
let flipped = false, swinging = 0;
const setFlip = (f) => {
flipped = f;
carrier.style.setProperty('--flip', f ? '180deg' : '0deg');
front.inert = f; // hidden face leaves the tab order AND the a11y tree
back.inert = !f;
btns.forEach((b) => b.setAttribute('aria-pressed', String(f)));
carrier.removeAttribute('data-live'); // fall back to the slow 700ms swing
clearTimeout(swinging);
swinging = setTimeout(() => { swinging = 0; }, 700);
(f ? back : front).querySelector('[data-flip]').focus({ preventScroll: true });
};
btns.forEach((b) => b.addEventListener('click', () => setFlip(!flipped)));
if (!matchMedia('(hover:hover) and (pointer:fine)').matches) return;
if (matchMedia('(prefers-reduced-motion: reduce)').matches) return;
const MAX = 10;
scene.addEventListener('pointermove', (e) => {
if (swinging) return; // let the swing finish clean
const r = scene.getBoundingClientRect();
const px = (e.clientX - r.left) / r.width, py = (e.clientY - r.top) / r.height;
const dir = flipped ? -1 : 1; // pointer-left is back-face-right
carrier.style.setProperty('--rx', ((0.5 - py) * MAX).toFixed(2) + 'deg');
carrier.style.setProperty('--ry', (dir * (px - 0.5) * MAX).toFixed(2) + 'deg');
carrier.setAttribute('data-live', '');
});
scene.addEventListener('pointerleave', () => {
carrier.removeAttribute('data-live');
carrier.style.setProperty('--rx', '0deg');
carrier.style.setProperty('--ry', '0deg');
});
});
})();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-ttc12-bg: #0e0d15;
--color-ttc12-bg: oklch(0.15 0.015 270);
--color-ttc12-lobe: #3b2f14b3;
--color-ttc12-lobe: oklch(0.25 0.05 80 / 0.7);
--color-ttc12-card1: #2a2517;
--color-ttc12-card1: oklch(0.23 0.03 80);
--color-ttc12-card2: #16151d;
--color-ttc12-card2: oklch(0.17 0.02 270);
--color-ttc12-acc: #e8b94a;
--color-ttc12-acc: oklch(0.8 0.14 80);
--color-ttc12-mut: #c6c3cf;
--color-ttc12-mut: oklch(0.82 0.015 280);
--font-ttc12-sans: 'Space Grotesk', system-ui, sans-serif;
--font-ttc12-mono: 'JetBrains Mono', ui-monospace, monospace;
}
/* Root rule: the full-bleed contract, stated in CSS as well as in the markup's utilities, so the
stage is sized before the browser build compiles. */
.ttc-12 {
width: 100%;
min-height: 100svh;
}@import "tailwindcss";
@theme {
--color-ttc12-bg: #0e0d15;
--color-ttc12-bg: oklch(0.15 0.015 270);
--color-ttc12-lobe: #3b2f14b3;
--color-ttc12-lobe: oklch(0.25 0.05 80 / 0.7);
--color-ttc12-card1: #2a2517;
--color-ttc12-card1: oklch(0.23 0.03 80);
--color-ttc12-card2: #16151d;
--color-ttc12-card2: oklch(0.17 0.02 270);
--color-ttc12-acc: #e8b94a;
--color-ttc12-acc: oklch(0.8 0.14 80);
--color-ttc12-mut: #c6c3cf;
--color-ttc12-mut: oklch(0.82 0.015 280);
--font-ttc12-sans: 'Space Grotesk', system-ui, sans-serif;
--font-ttc12-mono: 'JetBrains Mono', ui-monospace, monospace;
}
/* Root rule: the full-bleed contract, stated in CSS as well as in the markup's utilities, so the
stage is sized before the browser build compiles. */
.ttc-12 {
width: 100%;
min-height: 100svh;
}<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=Space+Grotesk:wght@400..700&family=JetBrains+Mono:wght@400..700&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=Space+Grotesk:wght@400..700&family=JetBrains+Mono:wght@400..700&display=swap">What changed in the port
Which parts became utilities, which stayed CSS, and why.
.is-liveand.is-flippingcollapsed into one attribute: the base transition IS the swing, anddata-live:overrides it only while tracking — one class and one CSS rule fewer.perspective:1100pxon the scene becameperspective-[1100px];backface-visibility+-webkit-becamebackface-hidden.- UI upgrade from the CSS original: the hidden face is now
inert(it was onlyaria-hidden, leaving an invisible, focusable button), and focus moves to the visible face's button after each flip. - UI upgrade: contact details became real
mailto:/tel:links with icons at 44px each, both flip buttons grew from ~34px to 44px, and the role line rose from 60% white tooklch(0.82).
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 3D Flip Card with Mouse Tilt (Click to Flip)
Source: https://codefronts.com/components/tailwind-css-3d-tilt-hover-cards/tailwind-3d-flip-card-mouse-tilt/
Tilt and flip in one transform: the card follows the cursor, and a click (or Enter/Space) swings it 180° to reveal contact details — and it keeps tilting while flipped. Flip and tilt compose in one rotateY(calc(var(--flip)_+_var(--ry))). The port fixes a real bug: the hidden face is now inert, so its buttons can no longer be tabbed to while invisible.
## HTML
```html
<section class="ttc-12 w-full min-h-svh grid place-items-center bg-ttc12-bg bg-[radial-gradient(80%_90%_at_70%_20%,var(--color-ttc12-lobe),transparent_60%)] px-6 py-14 font-ttc12-sans text-white" aria-label="3D flip card with mouse tilt demo">
<div class="grid w-full justify-items-center gap-8">
<div data-ttc12-scene class="w-full max-w-[19.5rem] perspective-[1100px]">
<div data-carrier class="relative h-60 transform-3d [transform:rotateX(var(--rx,0deg))_rotateY(calc(var(--flip,0deg)_+_var(--ry,0deg)))] transition-transform duration-700 ease-[cubic-bezier(0.3,0.8,0.3,1.1)] data-live:duration-90 data-live:ease-linear motion-reduce:transition-none">
<article data-face="front" class="absolute inset-0 flex flex-col rounded-[20px] border border-ttc12-acc/35 bg-linear-160 from-ttc12-card1 to-ttc12-card2 p-6 shadow-[0_26px_54px_-26px_oklch(0_0_0/0.8)] backface-hidden">
<p class="font-ttc12-mono text-[10.5px] font-bold tracking-[0.2em] text-ttc12-acc">FREELANCE · SF</p>
<h3 class="mt-3 text-[28px] leading-none font-bold tracking-[-0.02em]">Jonah Reyes</h3>
<p class="mt-1.5 text-sm text-ttc12-mut">Motion & Brand Designer</p>
<button data-flip type="button" aria-pressed="false" class="mt-auto inline-flex min-h-11 cursor-pointer items-center gap-2 self-start rounded-full bg-ttc12-acc px-5 text-[13px] font-semibold text-ttc12-bg transition-transform hover:scale-105 focus-visible:outline-3 focus-visible:outline-offset-3 focus-visible:outline-white">Contact <span aria-hidden="true">↻</span></button>
</article>
<article data-face="back" inert class="absolute inset-0 flex flex-col rounded-[20px] border border-ttc12-acc/35 bg-linear-160 from-ttc12-card1 to-ttc12-card2 p-6 shadow-[0_26px_54px_-26px_oklch(0_0_0/0.8)] backface-hidden [transform:rotateY(180deg)]">
<p class="font-ttc12-mono text-[10.5px] font-bold tracking-[0.2em] text-ttc12-acc">SAY HELLO</p>
<ul class="mt-2 grid text-[14px]">
<li><a href="mailto:jonah@studioreyes.co" class="flex min-h-11 items-center gap-3 rounded-lg text-white no-underline hover:text-ttc12-acc focus-visible:outline-2 focus-visible:outline-ttc12-acc"><svg aria-hidden="true" viewBox="0 0 24 24" class="size-4 flex-none text-ttc12-acc" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="5" width="18" height="14" rx="2"/><path d="M3 7l9 6 9-6"/></svg>jonah@studioreyes.co</a></li>
<li><a href="tel:+14155550132" class="flex min-h-11 items-center gap-3 rounded-lg text-white no-underline hover:text-ttc12-acc focus-visible:outline-2 focus-visible:outline-ttc12-acc"><svg aria-hidden="true" viewBox="0 0 24 24" class="size-4 flex-none text-ttc12-acc" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"><path d="M5 3h4l2 5-2.5 1.5a11 11 0 0 0 6 6L16 13l5 2v4a2 2 0 0 1-2 2A16 16 0 0 1 3 5a2 2 0 0 1 2-2z"/></svg>+1 (415) 555-0132</a></li>
</ul>
<button data-flip type="button" aria-pressed="true" class="mt-auto inline-flex min-h-11 cursor-pointer items-center gap-2 self-start rounded-full border border-white/25 px-5 text-[13px] font-semibold text-white transition-colors hover:bg-white/10 focus-visible:outline-3 focus-visible:outline-offset-3 focus-visible:outline-white"><span aria-hidden="true">↺</span> Back</button>
</article>
</div>
</div>
<p aria-hidden="true" class="rounded-full border border-white/12 bg-white/5 px-3.5 py-2 font-ttc12-mono text-[11.5px] text-ttc12-mut">rotateY(calc(var(--flip)_+_var(--ry))) · backface-hidden · inert</p>
</div>
</section>
```
## CSS
```css
@import "tailwindcss";
@theme {
--color-ttc12-bg: #0e0d15;
--color-ttc12-bg: oklch(0.15 0.015 270);
--color-ttc12-lobe: #3b2f14b3;
--color-ttc12-lobe: oklch(0.25 0.05 80 / 0.7);
--color-ttc12-card1: #2a2517;
--color-ttc12-card1: oklch(0.23 0.03 80);
--color-ttc12-card2: #16151d;
--color-ttc12-card2: oklch(0.17 0.02 270);
--color-ttc12-acc: #e8b94a;
--color-ttc12-acc: oklch(0.8 0.14 80);
--color-ttc12-mut: #c6c3cf;
--color-ttc12-mut: oklch(0.82 0.015 280);
--font-ttc12-sans: 'Space Grotesk', system-ui, sans-serif;
--font-ttc12-mono: 'JetBrains Mono', ui-monospace, monospace;
}
/* Root rule: the full-bleed contract, stated in CSS as well as in the markup's utilities, so the
stage is sized before the browser build compiles. */
.ttc-12 {
width: 100%;
min-height: 100svh;
}
```
## JavaScript
```js
(() => {
document.querySelectorAll('[data-ttc12-scene]').forEach((scene) => {
if (scene.dataset.wired) return;
scene.dataset.wired = '1';
const carrier = scene.querySelector('[data-carrier]');
const front = carrier.querySelector('[data-face="front"]');
const back = carrier.querySelector('[data-face="back"]');
const btns = carrier.querySelectorAll('[data-flip]');
let flipped = false, swinging = 0;
const setFlip = (f) => {
flipped = f;
carrier.style.setProperty('--flip', f ? '180deg' : '0deg');
front.inert = f; // hidden face leaves the tab order AND the a11y tree
back.inert = !f;
btns.forEach((b) => b.setAttribute('aria-pressed', String(f)));
carrier.removeAttribute('data-live'); // fall back to the slow 700ms swing
clearTimeout(swinging);
swinging = setTimeout(() => { swinging = 0; }, 700);
(f ? back : front).querySelector('[data-flip]').focus({ preventScroll: true });
};
btns.forEach((b) => b.addEventListener('click', () => setFlip(!flipped)));
if (!matchMedia('(hover:hover) and (pointer:fine)').matches) return;
if (matchMedia('(prefers-reduced-motion: reduce)').matches) return;
const MAX = 10;
scene.addEventListener('pointermove', (e) => {
if (swinging) return; // let the swing finish clean
const r = scene.getBoundingClientRect();
const px = (e.clientX - r.left) / r.width, py = (e.clientY - r.top) / r.height;
const dir = flipped ? -1 : 1; // pointer-left is back-face-right
carrier.style.setProperty('--rx', ((0.5 - py) * MAX).toFixed(2) + 'deg');
carrier.style.setProperty('--ry', (dir * (px - 0.5) * MAX).toFixed(2) + 'deg');
carrier.setAttribute('data-live', '');
});
scene.addEventListener('pointerleave', () => {
carrier.removeAttribute('data-live');
carrier.style.setProperty('--rx', '0deg');
carrier.style.setProperty('--ry', '0deg');
});
});
})();
```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 3D Flip Card with Mouse Tilt (Click to Flip)
Source: https://codefronts.com/components/tailwind-css-3d-tilt-hover-cards/tailwind-3d-flip-card-mouse-tilt/
Tilt and flip in one transform: the card follows the cursor, and a click (or Enter/Space) swings it 180° to reveal contact details — and it keeps tilting while flipped. Flip and tilt compose in one rotateY(calc(var(--flip)_+_var(--ry))). The port fixes a real bug: the hidden face is now inert, so its buttons can no longer be tabbed to while invisible.
## HTML
```html
<section class="ttc-12 w-full min-h-svh grid place-items-center bg-ttc12-bg bg-[radial-gradient(80%_90%_at_70%_20%,var(--color-ttc12-lobe),transparent_60%)] px-6 py-14 font-ttc12-sans text-white" aria-label="3D flip card with mouse tilt demo">
<div class="grid w-full justify-items-center gap-8">
<div data-ttc12-scene class="w-full max-w-[19.5rem] perspective-[1100px]">
<div data-carrier class="relative h-60 transform-3d [transform:rotateX(var(--rx,0deg))_rotateY(calc(var(--flip,0deg)_+_var(--ry,0deg)))] transition-transform duration-700 ease-[cubic-bezier(0.3,0.8,0.3,1.1)] data-live:duration-90 data-live:ease-linear motion-reduce:transition-none">
<article data-face="front" class="absolute inset-0 flex flex-col rounded-[20px] border border-ttc12-acc/35 bg-linear-160 from-ttc12-card1 to-ttc12-card2 p-6 shadow-[0_26px_54px_-26px_oklch(0_0_0/0.8)] backface-hidden">
<p class="font-ttc12-mono text-[10.5px] font-bold tracking-[0.2em] text-ttc12-acc">FREELANCE · SF</p>
<h3 class="mt-3 text-[28px] leading-none font-bold tracking-[-0.02em]">Jonah Reyes</h3>
<p class="mt-1.5 text-sm text-ttc12-mut">Motion & Brand Designer</p>
<button data-flip type="button" aria-pressed="false" class="mt-auto inline-flex min-h-11 cursor-pointer items-center gap-2 self-start rounded-full bg-ttc12-acc px-5 text-[13px] font-semibold text-ttc12-bg transition-transform hover:scale-105 focus-visible:outline-3 focus-visible:outline-offset-3 focus-visible:outline-white">Contact <span aria-hidden="true">↻</span></button>
</article>
<article data-face="back" inert class="absolute inset-0 flex flex-col rounded-[20px] border border-ttc12-acc/35 bg-linear-160 from-ttc12-card1 to-ttc12-card2 p-6 shadow-[0_26px_54px_-26px_oklch(0_0_0/0.8)] backface-hidden [transform:rotateY(180deg)]">
<p class="font-ttc12-mono text-[10.5px] font-bold tracking-[0.2em] text-ttc12-acc">SAY HELLO</p>
<ul class="mt-2 grid text-[14px]">
<li><a href="mailto:jonah@studioreyes.co" class="flex min-h-11 items-center gap-3 rounded-lg text-white no-underline hover:text-ttc12-acc focus-visible:outline-2 focus-visible:outline-ttc12-acc"><svg aria-hidden="true" viewBox="0 0 24 24" class="size-4 flex-none text-ttc12-acc" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="5" width="18" height="14" rx="2"/><path d="M3 7l9 6 9-6"/></svg>jonah@studioreyes.co</a></li>
<li><a href="tel:+14155550132" class="flex min-h-11 items-center gap-3 rounded-lg text-white no-underline hover:text-ttc12-acc focus-visible:outline-2 focus-visible:outline-ttc12-acc"><svg aria-hidden="true" viewBox="0 0 24 24" class="size-4 flex-none text-ttc12-acc" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"><path d="M5 3h4l2 5-2.5 1.5a11 11 0 0 0 6 6L16 13l5 2v4a2 2 0 0 1-2 2A16 16 0 0 1 3 5a2 2 0 0 1 2-2z"/></svg>+1 (415) 555-0132</a></li>
</ul>
<button data-flip type="button" aria-pressed="true" class="mt-auto inline-flex min-h-11 cursor-pointer items-center gap-2 self-start rounded-full border border-white/25 px-5 text-[13px] font-semibold text-white transition-colors hover:bg-white/10 focus-visible:outline-3 focus-visible:outline-offset-3 focus-visible:outline-white"><span aria-hidden="true">↺</span> Back</button>
</article>
</div>
</div>
<p aria-hidden="true" class="rounded-full border border-white/12 bg-white/5 px-3.5 py-2 font-ttc12-mono text-[11.5px] text-ttc12-mut">rotateY(calc(var(--flip)_+_var(--ry))) · backface-hidden · inert</p>
</div>
</section>
```
## CSS
```css
@import "tailwindcss";
@theme {
--color-ttc12-bg: #0e0d15;
--color-ttc12-bg: oklch(0.15 0.015 270);
--color-ttc12-lobe: #3b2f14b3;
--color-ttc12-lobe: oklch(0.25 0.05 80 / 0.7);
--color-ttc12-card1: #2a2517;
--color-ttc12-card1: oklch(0.23 0.03 80);
--color-ttc12-card2: #16151d;
--color-ttc12-card2: oklch(0.17 0.02 270);
--color-ttc12-acc: #e8b94a;
--color-ttc12-acc: oklch(0.8 0.14 80);
--color-ttc12-mut: #c6c3cf;
--color-ttc12-mut: oklch(0.82 0.015 280);
--font-ttc12-sans: 'Space Grotesk', system-ui, sans-serif;
--font-ttc12-mono: 'JetBrains Mono', ui-monospace, monospace;
}
/* Root rule: the full-bleed contract, stated in CSS as well as in the markup's utilities, so the
stage is sized before the browser build compiles. */
.ttc-12 {
width: 100%;
min-height: 100svh;
}
```
## JavaScript
```js
(() => {
document.querySelectorAll('[data-ttc12-scene]').forEach((scene) => {
if (scene.dataset.wired) return;
scene.dataset.wired = '1';
const carrier = scene.querySelector('[data-carrier]');
const front = carrier.querySelector('[data-face="front"]');
const back = carrier.querySelector('[data-face="back"]');
const btns = carrier.querySelectorAll('[data-flip]');
let flipped = false, swinging = 0;
const setFlip = (f) => {
flipped = f;
carrier.style.setProperty('--flip', f ? '180deg' : '0deg');
front.inert = f; // hidden face leaves the tab order AND the a11y tree
back.inert = !f;
btns.forEach((b) => b.setAttribute('aria-pressed', String(f)));
carrier.removeAttribute('data-live'); // fall back to the slow 700ms swing
clearTimeout(swinging);
swinging = setTimeout(() => { swinging = 0; }, 700);
(f ? back : front).querySelector('[data-flip]').focus({ preventScroll: true });
};
btns.forEach((b) => b.addEventListener('click', () => setFlip(!flipped)));
if (!matchMedia('(hover:hover) and (pointer:fine)').matches) return;
if (matchMedia('(prefers-reduced-motion: reduce)').matches) return;
const MAX = 10;
scene.addEventListener('pointermove', (e) => {
if (swinging) return; // let the swing finish clean
const r = scene.getBoundingClientRect();
const px = (e.clientX - r.left) / r.width, py = (e.clientY - r.top) / r.height;
const dir = flipped ? -1 : 1; // pointer-left is back-face-right
carrier.style.setProperty('--rx', ((0.5 - py) * MAX).toFixed(2) + 'deg');
carrier.style.setProperty('--ry', (dir * (px - 0.5) * MAX).toFixed(2) + 'deg');
carrier.setAttribute('data-live', '');
});
scene.addEventListener('pointerleave', () => {
carrier.removeAttribute('data-live');
carrier.style.setProperty('--rx', '0deg');
carrier.style.setProperty('--ry', '0deg');
});
});
})();
```How this works
State is one custom property. --flip is 0deg or 180deg; both buttons carry aria-pressed and toggle it. Because flip and tilt share one rotateY(calc(…)), they share one transition and can never fight.
Two speeds, no extra class. The carrier's base transition is a 700ms bezier — the swing. While the pointer moves, data-live:duration-90 data-live:ease-linear takes over. On flip the script removes data-live and ignores pointer moves for 700ms, so the slow swing always plays cleanly.
The hidden face is inert. The original set aria-hidden on the back face but left its button focusable — a keyboard user could tab onto an invisible control. inert removes it from both tab order and the accessibility tree in one attribute.
Make it yours
- Top-flip (business card style): move
--flipinto therotateX(calc(…)). - Weightier stock:
duration-900on the carrier. - Put a QR code, vCard download or form on the back — it's a normal DOM face, fully interactive when shown.
- Flip on hover-intent: start a 300ms timer on
pointerenter, clear it on leave.
Gotchas — read before shipping
backface-hiddenbelongs on each FACE, never the carrier, or the back face vanishes entirely.- Invert the Y tilt while flipped or the card tilts away from the cursor.
calc()inside an arbitrary value needs underscores around+(_+_) — without spaces the calc is invalid.
Browser support
| Chrome | Safari | Firefox | Edge |
|---|---|---|---|
| 111+ | 16.4+ | 128+ | 111+ |
inert is Chrome 102 / Safari 15.5 / Firefox 112; the classic flip itself dates to 2012.