08 / 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 Blog Card Hover Effect with Subtle 3D Tilt
An editorial card that respects reading: tilt capped at ±4.5° so the headline never smears, a cover that eases to scale-104, and an author row that slides open with the grid-rows-[0fr] → grid-rows-[1fr] trick. The port fixes the original's biggest miss: the reveal now also opens on keyboard focus (group-focus-visible/link:) and is always open on touch.
Untitled UI Pro: 5,000+ React + Tailwind CSS v4 components · from $349 one-time
Untitled UI React · Affiliate link
The code
<section class="ttc-08 w-full min-h-svh grid place-items-center bg-ttc08-bg bg-linear-to-b from-ttc08-bg to-ttc08-bg2 px-6 py-14 font-ttc08-serif text-ttc08-ink" aria-label="Blog article card with subtle 3D tilt demo">
<div class="grid w-full justify-items-center gap-8">
<a href="#" class="group/link block w-full max-w-[21rem] rounded-[20px] no-underline text-inherit focus-visible:outline-3 focus-visible:outline-offset-5 focus-visible:outline-ttc08-acc">
<article data-ttc08-card class="group/card overflow-hidden rounded-[18px] border border-ttc08-line bg-ttc08-card shadow-[0_16px_40px_-28px_var(--color-ttc08-shadow)] [transform:perspective(1300px)_rotateX(var(--rx,0deg))_rotateY(var(--ry,0deg))] transition-[transform,box-shadow] duration-550 ease-[cubic-bezier(0.2,0.9,0.3,1.05)] data-live:duration-90 data-live:ease-linear data-live:shadow-[0_26px_54px_-26px_var(--color-ttc08-shadow)] motion-reduce:transition-none">
<div aria-hidden="true" class="aspect-video overflow-hidden bg-ttc08-cover">
<img class="size-full object-cover transition-[scale] duration-550 ease-[cubic-bezier(0.2,0.9,0.3,1.05)] group-data-live/card:scale-104 motion-reduce:transition-none" src="https://picsum.photos/seed/tokens-grammar/640/360" alt="" loading="lazy">
</div>
<div class="px-6 pt-5 pb-5">
<p class="font-ttc08-sans text-[11px] font-bold tracking-[0.14em] uppercase text-ttc08-acc">Design Systems</p>
<h3 class="mt-2 text-[22px] leading-[1.22] font-medium tracking-[-0.01em] text-pretty">Why Your Tokens Need a Grammar, Not a Dictionary</h3>
<div class="grid grid-rows-[0fr] transition-[grid-template-rows] duration-300 ease-out group-data-live/card:grid-rows-[1fr] group-focus-visible/link:grid-rows-[1fr] pointer-coarse:grid-rows-[1fr] motion-reduce:grid-rows-[1fr] motion-reduce:transition-none">
<div class="min-h-0 overflow-hidden">
<div class="flex items-center gap-2.5 pt-4 font-ttc08-sans text-[12.5px] text-ttc08-mut">
<img class="size-7 rounded-full object-cover" src="https://images.unsplash.com/photo-1438761681033-6461ffad8d80?q=80&w=96&auto=format&fit=crop" alt="" width="28" height="28" loading="lazy">
<span class="font-semibold text-ttc08-ink">Priya Sharma</span><span aria-hidden="true">·</span><span>7 min read</span>
</div>
</div>
</div>
</div>
</article>
</a>
<p aria-hidden="true" class="rounded-full border border-ttc08-line bg-white/60 px-3.5 py-2 font-ttc08-mono text-[11.5px] text-ttc08-mut">grid-rows-[0fr] → [1fr] · group-focus-visible/link: · pointer-coarse:</p>
</div>
</section><section class="ttc-08 w-full min-h-svh grid place-items-center bg-ttc08-bg bg-linear-to-b from-ttc08-bg to-ttc08-bg2 px-6 py-14 font-ttc08-serif text-ttc08-ink" aria-label="Blog article card with subtle 3D tilt demo">
<div class="grid w-full justify-items-center gap-8">
<a href="#" class="group/link block w-full max-w-[21rem] rounded-[20px] no-underline text-inherit focus-visible:outline-3 focus-visible:outline-offset-5 focus-visible:outline-ttc08-acc">
<article data-ttc08-card class="group/card overflow-hidden rounded-[18px] border border-ttc08-line bg-ttc08-card shadow-[0_16px_40px_-28px_var(--color-ttc08-shadow)] [transform:perspective(1300px)_rotateX(var(--rx,0deg))_rotateY(var(--ry,0deg))] transition-[transform,box-shadow] duration-550 ease-[cubic-bezier(0.2,0.9,0.3,1.05)] data-live:duration-90 data-live:ease-linear data-live:shadow-[0_26px_54px_-26px_var(--color-ttc08-shadow)] motion-reduce:transition-none">
<div aria-hidden="true" class="aspect-video overflow-hidden bg-ttc08-cover">
<img class="size-full object-cover transition-[scale] duration-550 ease-[cubic-bezier(0.2,0.9,0.3,1.05)] group-data-live/card:scale-104 motion-reduce:transition-none" src="https://picsum.photos/seed/tokens-grammar/640/360" alt="" loading="lazy">
</div>
<div class="px-6 pt-5 pb-5">
<p class="font-ttc08-sans text-[11px] font-bold tracking-[0.14em] uppercase text-ttc08-acc">Design Systems</p>
<h3 class="mt-2 text-[22px] leading-[1.22] font-medium tracking-[-0.01em] text-pretty">Why Your Tokens Need a Grammar, Not a Dictionary</h3>
<div class="grid grid-rows-[0fr] transition-[grid-template-rows] duration-300 ease-out group-data-live/card:grid-rows-[1fr] group-focus-visible/link:grid-rows-[1fr] pointer-coarse:grid-rows-[1fr] motion-reduce:grid-rows-[1fr] motion-reduce:transition-none">
<div class="min-h-0 overflow-hidden">
<div class="flex items-center gap-2.5 pt-4 font-ttc08-sans text-[12.5px] text-ttc08-mut">
<img class="size-7 rounded-full object-cover" src="https://images.unsplash.com/photo-1438761681033-6461ffad8d80?q=80&w=96&auto=format&fit=crop" alt="" width="28" height="28" loading="lazy">
<span class="font-semibold text-ttc08-ink">Priya Sharma</span><span aria-hidden="true">·</span><span>7 min read</span>
</div>
</div>
</div>
</div>
</article>
</a>
<p aria-hidden="true" class="rounded-full border border-ttc08-line bg-white/60 px-3.5 py-2 font-ttc08-mono text-[11.5px] text-ttc08-mut">grid-rows-[0fr] → [1fr] · group-focus-visible/link: · pointer-coarse:</p>
</div>
</section>@import "tailwindcss";
@theme {
--color-ttc08-bg: #f8f5ef;
--color-ttc08-bg: oklch(0.97 0.008 85);
--color-ttc08-bg2: #efe9df;
--color-ttc08-bg2: oklch(0.935 0.014 75);
--color-ttc08-card: #fdfcf9;
--color-ttc08-card: oklch(0.99 0.004 85);
--color-ttc08-cover: #b98e62;
--color-ttc08-cover: oklch(0.67 0.08 60);
--color-ttc08-line: #e3dccf;
--color-ttc08-line: oklch(0.89 0.015 80);
--color-ttc08-ink: #2c241c;
--color-ttc08-ink: oklch(0.26 0.02 60);
--color-ttc08-mut: #6a5e52;
--color-ttc08-mut: oklch(0.48 0.025 60);
--color-ttc08-acc: #a8562c;
--color-ttc08-acc: oklch(0.53 0.13 48);
--color-ttc08-shadow: #6b543899;
--color-ttc08-shadow: oklch(0.42 0.05 70 / 0.6);
--font-ttc08-serif: 'Newsreader', Georgia, serif;
--font-ttc08-sans: 'Plus Jakarta Sans', system-ui, sans-serif;
--font-ttc08-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-08 {
width: 100%;
min-height: 100svh;
}@import "tailwindcss";
@theme {
--color-ttc08-bg: #f8f5ef;
--color-ttc08-bg: oklch(0.97 0.008 85);
--color-ttc08-bg2: #efe9df;
--color-ttc08-bg2: oklch(0.935 0.014 75);
--color-ttc08-card: #fdfcf9;
--color-ttc08-card: oklch(0.99 0.004 85);
--color-ttc08-cover: #b98e62;
--color-ttc08-cover: oklch(0.67 0.08 60);
--color-ttc08-line: #e3dccf;
--color-ttc08-line: oklch(0.89 0.015 80);
--color-ttc08-ink: #2c241c;
--color-ttc08-ink: oklch(0.26 0.02 60);
--color-ttc08-mut: #6a5e52;
--color-ttc08-mut: oklch(0.48 0.025 60);
--color-ttc08-acc: #a8562c;
--color-ttc08-acc: oklch(0.53 0.13 48);
--color-ttc08-shadow: #6b543899;
--color-ttc08-shadow: oklch(0.42 0.05 70 / 0.6);
--font-ttc08-serif: 'Newsreader', Georgia, serif;
--font-ttc08-sans: 'Plus Jakarta Sans', system-ui, sans-serif;
--font-ttc08-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-08 {
width: 100%;
min-height: 100svh;
}(() => {
document.querySelectorAll('[data-ttc08-card]').forEach((card) => {
if (card.dataset.wired) return;
card.dataset.wired = '1';
if (!matchMedia('(hover:hover) and (pointer:fine)').matches) return;
if (matchMedia('(prefers-reduced-motion: reduce)').matches) return;
const MAX = 4.5; // legibility cap: headlines shimmer past ~5 degrees
card.addEventListener('pointermove', (e) => {
const r = card.getBoundingClientRect();
const px = (e.clientX - r.left) / r.width, py = (e.clientY - r.top) / r.height;
card.style.setProperty('--rx', ((0.5 - py) * MAX).toFixed(2) + 'deg');
card.style.setProperty('--ry', ((px - 0.5) * MAX).toFixed(2) + 'deg');
card.setAttribute('data-live', ''); // also opens the 0fr -> 1fr author row
});
card.addEventListener('pointerleave', () => {
card.removeAttribute('data-live');
card.style.setProperty('--rx', '0deg');
card.style.setProperty('--ry', '0deg');
});
});
})();(() => {
document.querySelectorAll('[data-ttc08-card]').forEach((card) => {
if (card.dataset.wired) return;
card.dataset.wired = '1';
if (!matchMedia('(hover:hover) and (pointer:fine)').matches) return;
if (matchMedia('(prefers-reduced-motion: reduce)').matches) return;
const MAX = 4.5; // legibility cap: headlines shimmer past ~5 degrees
card.addEventListener('pointermove', (e) => {
const r = card.getBoundingClientRect();
const px = (e.clientX - r.left) / r.width, py = (e.clientY - r.top) / r.height;
card.style.setProperty('--rx', ((0.5 - py) * MAX).toFixed(2) + 'deg');
card.style.setProperty('--ry', ((px - 0.5) * MAX).toFixed(2) + 'deg');
card.setAttribute('data-live', ''); // also opens the 0fr -> 1fr author row
});
card.addEventListener('pointerleave', () => {
card.removeAttribute('data-live');
card.style.setProperty('--rx', '0deg');
card.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-ttc08-bg: #f8f5ef;
--color-ttc08-bg: oklch(0.97 0.008 85);
--color-ttc08-bg2: #efe9df;
--color-ttc08-bg2: oklch(0.935 0.014 75);
--color-ttc08-card: #fdfcf9;
--color-ttc08-card: oklch(0.99 0.004 85);
--color-ttc08-cover: #b98e62;
--color-ttc08-cover: oklch(0.67 0.08 60);
--color-ttc08-line: #e3dccf;
--color-ttc08-line: oklch(0.89 0.015 80);
--color-ttc08-ink: #2c241c;
--color-ttc08-ink: oklch(0.26 0.02 60);
--color-ttc08-mut: #6a5e52;
--color-ttc08-mut: oklch(0.48 0.025 60);
--color-ttc08-acc: #a8562c;
--color-ttc08-acc: oklch(0.53 0.13 48);
--color-ttc08-shadow: #6b543899;
--color-ttc08-shadow: oklch(0.42 0.05 70 / 0.6);
--font-ttc08-serif: 'Newsreader', Georgia, serif;
--font-ttc08-sans: 'Plus Jakarta Sans', system-ui, sans-serif;
--font-ttc08-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-08 {
width: 100%;
min-height: 100svh;
}@import "tailwindcss";
@theme {
--color-ttc08-bg: #f8f5ef;
--color-ttc08-bg: oklch(0.97 0.008 85);
--color-ttc08-bg2: #efe9df;
--color-ttc08-bg2: oklch(0.935 0.014 75);
--color-ttc08-card: #fdfcf9;
--color-ttc08-card: oklch(0.99 0.004 85);
--color-ttc08-cover: #b98e62;
--color-ttc08-cover: oklch(0.67 0.08 60);
--color-ttc08-line: #e3dccf;
--color-ttc08-line: oklch(0.89 0.015 80);
--color-ttc08-ink: #2c241c;
--color-ttc08-ink: oklch(0.26 0.02 60);
--color-ttc08-mut: #6a5e52;
--color-ttc08-mut: oklch(0.48 0.025 60);
--color-ttc08-acc: #a8562c;
--color-ttc08-acc: oklch(0.53 0.13 48);
--color-ttc08-shadow: #6b543899;
--color-ttc08-shadow: oklch(0.42 0.05 70 / 0.6);
--font-ttc08-serif: 'Newsreader', Georgia, serif;
--font-ttc08-sans: 'Plus Jakarta Sans', system-ui, sans-serif;
--font-ttc08-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-08 {
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=Newsreader:ital,opsz,wght@0,6..72,400..700;1,6..72,400..700&family=Plus+Jakarta+Sans:wght@400..800&family=JetBrains+Mono:wght@400..600&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=Newsreader:ital,opsz,wght@0,6..72,400..700;1,6..72,400..700&family=Plus+Jakarta+Sans:wght@400..800&family=JetBrains+Mono:wght@400..600&display=swap">What changed in the port
Which parts became utilities, which stayed CSS, and why.
- The
@media (hover:none)and reduced-motion overrides becamepointer-coarse:grid-rows-[1fr]andmotion-reduce:grid-rows-[1fr]on the same element as the reveal. - The original's
translateZ(30px)on the cover was a silent no-op (the card'soverflow:hiddenflattens preserve-3d); the port drops it and says why ingotchas. - UI upgrade from the CSS original: keyboard users now get the author row —
group-focus-visible/link:grid-rows-[1fr]opens it when the link is tabbed to; before, it was hover-only. - UI upgrade: Georgia became Newsreader's optical-size serif, the gradient author dot became a real avatar, and meta copy darkened from
oklch(0.50)to0.48with the author name at full ink.
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 Blog Card Hover Effect with Subtle 3D Tilt
Source: https://codefronts.com/components/tailwind-css-3d-tilt-hover-cards/tailwind-blog-card-hover-effect-subtle-3d-tilt/
An editorial card that respects reading: tilt capped at ±4.5° so the headline never smears, a cover that eases to scale-104, and an author row that slides open with the grid-rows-[0fr] → grid-rows-[1fr] trick. The port fixes the original's biggest miss: the reveal now also opens on keyboard focus (group-focus-visible/link:) and is always open on touch.
## HTML
```html
<section class="ttc-08 w-full min-h-svh grid place-items-center bg-ttc08-bg bg-linear-to-b from-ttc08-bg to-ttc08-bg2 px-6 py-14 font-ttc08-serif text-ttc08-ink" aria-label="Blog article card with subtle 3D tilt demo">
<div class="grid w-full justify-items-center gap-8">
<a href="#" class="group/link block w-full max-w-[21rem] rounded-[20px] no-underline text-inherit focus-visible:outline-3 focus-visible:outline-offset-5 focus-visible:outline-ttc08-acc">
<article data-ttc08-card class="group/card overflow-hidden rounded-[18px] border border-ttc08-line bg-ttc08-card shadow-[0_16px_40px_-28px_var(--color-ttc08-shadow)] [transform:perspective(1300px)_rotateX(var(--rx,0deg))_rotateY(var(--ry,0deg))] transition-[transform,box-shadow] duration-550 ease-[cubic-bezier(0.2,0.9,0.3,1.05)] data-live:duration-90 data-live:ease-linear data-live:shadow-[0_26px_54px_-26px_var(--color-ttc08-shadow)] motion-reduce:transition-none">
<div aria-hidden="true" class="aspect-video overflow-hidden bg-ttc08-cover">
<img class="size-full object-cover transition-[scale] duration-550 ease-[cubic-bezier(0.2,0.9,0.3,1.05)] group-data-live/card:scale-104 motion-reduce:transition-none" src="https://picsum.photos/seed/tokens-grammar/640/360" alt="" loading="lazy">
</div>
<div class="px-6 pt-5 pb-5">
<p class="font-ttc08-sans text-[11px] font-bold tracking-[0.14em] uppercase text-ttc08-acc">Design Systems</p>
<h3 class="mt-2 text-[22px] leading-[1.22] font-medium tracking-[-0.01em] text-pretty">Why Your Tokens Need a Grammar, Not a Dictionary</h3>
<div class="grid grid-rows-[0fr] transition-[grid-template-rows] duration-300 ease-out group-data-live/card:grid-rows-[1fr] group-focus-visible/link:grid-rows-[1fr] pointer-coarse:grid-rows-[1fr] motion-reduce:grid-rows-[1fr] motion-reduce:transition-none">
<div class="min-h-0 overflow-hidden">
<div class="flex items-center gap-2.5 pt-4 font-ttc08-sans text-[12.5px] text-ttc08-mut">
<img class="size-7 rounded-full object-cover" src="https://images.unsplash.com/photo-1438761681033-6461ffad8d80?q=80&w=96&auto=format&fit=crop" alt="" width="28" height="28" loading="lazy">
<span class="font-semibold text-ttc08-ink">Priya Sharma</span><span aria-hidden="true">·</span><span>7 min read</span>
</div>
</div>
</div>
</div>
</article>
</a>
<p aria-hidden="true" class="rounded-full border border-ttc08-line bg-white/60 px-3.5 py-2 font-ttc08-mono text-[11.5px] text-ttc08-mut">grid-rows-[0fr] → [1fr] · group-focus-visible/link: · pointer-coarse:</p>
</div>
</section>
```
## CSS
```css
@import "tailwindcss";
@theme {
--color-ttc08-bg: #f8f5ef;
--color-ttc08-bg: oklch(0.97 0.008 85);
--color-ttc08-bg2: #efe9df;
--color-ttc08-bg2: oklch(0.935 0.014 75);
--color-ttc08-card: #fdfcf9;
--color-ttc08-card: oklch(0.99 0.004 85);
--color-ttc08-cover: #b98e62;
--color-ttc08-cover: oklch(0.67 0.08 60);
--color-ttc08-line: #e3dccf;
--color-ttc08-line: oklch(0.89 0.015 80);
--color-ttc08-ink: #2c241c;
--color-ttc08-ink: oklch(0.26 0.02 60);
--color-ttc08-mut: #6a5e52;
--color-ttc08-mut: oklch(0.48 0.025 60);
--color-ttc08-acc: #a8562c;
--color-ttc08-acc: oklch(0.53 0.13 48);
--color-ttc08-shadow: #6b543899;
--color-ttc08-shadow: oklch(0.42 0.05 70 / 0.6);
--font-ttc08-serif: 'Newsreader', Georgia, serif;
--font-ttc08-sans: 'Plus Jakarta Sans', system-ui, sans-serif;
--font-ttc08-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-08 {
width: 100%;
min-height: 100svh;
}
```
## JavaScript
```js
(() => {
document.querySelectorAll('[data-ttc08-card]').forEach((card) => {
if (card.dataset.wired) return;
card.dataset.wired = '1';
if (!matchMedia('(hover:hover) and (pointer:fine)').matches) return;
if (matchMedia('(prefers-reduced-motion: reduce)').matches) return;
const MAX = 4.5; // legibility cap: headlines shimmer past ~5 degrees
card.addEventListener('pointermove', (e) => {
const r = card.getBoundingClientRect();
const px = (e.clientX - r.left) / r.width, py = (e.clientY - r.top) / r.height;
card.style.setProperty('--rx', ((0.5 - py) * MAX).toFixed(2) + 'deg');
card.style.setProperty('--ry', ((px - 0.5) * MAX).toFixed(2) + 'deg');
card.setAttribute('data-live', ''); // also opens the 0fr -> 1fr author row
});
card.addEventListener('pointerleave', () => {
card.removeAttribute('data-live');
card.style.setProperty('--rx', '0deg');
card.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 Blog Card Hover Effect with Subtle 3D Tilt
Source: https://codefronts.com/components/tailwind-css-3d-tilt-hover-cards/tailwind-blog-card-hover-effect-subtle-3d-tilt/
An editorial card that respects reading: tilt capped at ±4.5° so the headline never smears, a cover that eases to scale-104, and an author row that slides open with the grid-rows-[0fr] → grid-rows-[1fr] trick. The port fixes the original's biggest miss: the reveal now also opens on keyboard focus (group-focus-visible/link:) and is always open on touch.
## HTML
```html
<section class="ttc-08 w-full min-h-svh grid place-items-center bg-ttc08-bg bg-linear-to-b from-ttc08-bg to-ttc08-bg2 px-6 py-14 font-ttc08-serif text-ttc08-ink" aria-label="Blog article card with subtle 3D tilt demo">
<div class="grid w-full justify-items-center gap-8">
<a href="#" class="group/link block w-full max-w-[21rem] rounded-[20px] no-underline text-inherit focus-visible:outline-3 focus-visible:outline-offset-5 focus-visible:outline-ttc08-acc">
<article data-ttc08-card class="group/card overflow-hidden rounded-[18px] border border-ttc08-line bg-ttc08-card shadow-[0_16px_40px_-28px_var(--color-ttc08-shadow)] [transform:perspective(1300px)_rotateX(var(--rx,0deg))_rotateY(var(--ry,0deg))] transition-[transform,box-shadow] duration-550 ease-[cubic-bezier(0.2,0.9,0.3,1.05)] data-live:duration-90 data-live:ease-linear data-live:shadow-[0_26px_54px_-26px_var(--color-ttc08-shadow)] motion-reduce:transition-none">
<div aria-hidden="true" class="aspect-video overflow-hidden bg-ttc08-cover">
<img class="size-full object-cover transition-[scale] duration-550 ease-[cubic-bezier(0.2,0.9,0.3,1.05)] group-data-live/card:scale-104 motion-reduce:transition-none" src="https://picsum.photos/seed/tokens-grammar/640/360" alt="" loading="lazy">
</div>
<div class="px-6 pt-5 pb-5">
<p class="font-ttc08-sans text-[11px] font-bold tracking-[0.14em] uppercase text-ttc08-acc">Design Systems</p>
<h3 class="mt-2 text-[22px] leading-[1.22] font-medium tracking-[-0.01em] text-pretty">Why Your Tokens Need a Grammar, Not a Dictionary</h3>
<div class="grid grid-rows-[0fr] transition-[grid-template-rows] duration-300 ease-out group-data-live/card:grid-rows-[1fr] group-focus-visible/link:grid-rows-[1fr] pointer-coarse:grid-rows-[1fr] motion-reduce:grid-rows-[1fr] motion-reduce:transition-none">
<div class="min-h-0 overflow-hidden">
<div class="flex items-center gap-2.5 pt-4 font-ttc08-sans text-[12.5px] text-ttc08-mut">
<img class="size-7 rounded-full object-cover" src="https://images.unsplash.com/photo-1438761681033-6461ffad8d80?q=80&w=96&auto=format&fit=crop" alt="" width="28" height="28" loading="lazy">
<span class="font-semibold text-ttc08-ink">Priya Sharma</span><span aria-hidden="true">·</span><span>7 min read</span>
</div>
</div>
</div>
</div>
</article>
</a>
<p aria-hidden="true" class="rounded-full border border-ttc08-line bg-white/60 px-3.5 py-2 font-ttc08-mono text-[11.5px] text-ttc08-mut">grid-rows-[0fr] → [1fr] · group-focus-visible/link: · pointer-coarse:</p>
</div>
</section>
```
## CSS
```css
@import "tailwindcss";
@theme {
--color-ttc08-bg: #f8f5ef;
--color-ttc08-bg: oklch(0.97 0.008 85);
--color-ttc08-bg2: #efe9df;
--color-ttc08-bg2: oklch(0.935 0.014 75);
--color-ttc08-card: #fdfcf9;
--color-ttc08-card: oklch(0.99 0.004 85);
--color-ttc08-cover: #b98e62;
--color-ttc08-cover: oklch(0.67 0.08 60);
--color-ttc08-line: #e3dccf;
--color-ttc08-line: oklch(0.89 0.015 80);
--color-ttc08-ink: #2c241c;
--color-ttc08-ink: oklch(0.26 0.02 60);
--color-ttc08-mut: #6a5e52;
--color-ttc08-mut: oklch(0.48 0.025 60);
--color-ttc08-acc: #a8562c;
--color-ttc08-acc: oklch(0.53 0.13 48);
--color-ttc08-shadow: #6b543899;
--color-ttc08-shadow: oklch(0.42 0.05 70 / 0.6);
--font-ttc08-serif: 'Newsreader', Georgia, serif;
--font-ttc08-sans: 'Plus Jakarta Sans', system-ui, sans-serif;
--font-ttc08-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-08 {
width: 100%;
min-height: 100svh;
}
```
## JavaScript
```js
(() => {
document.querySelectorAll('[data-ttc08-card]').forEach((card) => {
if (card.dataset.wired) return;
card.dataset.wired = '1';
if (!matchMedia('(hover:hover) and (pointer:fine)').matches) return;
if (matchMedia('(prefers-reduced-motion: reduce)').matches) return;
const MAX = 4.5; // legibility cap: headlines shimmer past ~5 degrees
card.addEventListener('pointermove', (e) => {
const r = card.getBoundingClientRect();
const px = (e.clientX - r.left) / r.width, py = (e.clientY - r.top) / r.height;
card.style.setProperty('--rx', ((0.5 - py) * MAX).toFixed(2) + 'deg');
card.style.setProperty('--ry', ((px - 0.5) * MAX).toFixed(2) + 'deg');
card.setAttribute('data-live', ''); // also opens the 0fr -> 1fr author row
});
card.addEventListener('pointerleave', () => {
card.removeAttribute('data-live');
card.style.setProperty('--rx', '0deg');
card.style.setProperty('--ry', '0deg');
});
});
})();
```How this works
Two motion budgets. The card tilts within ±4.5° (text under ~14px starts shimmering past 5°). The cover grows to scale-104 inside its own clipping frame — parallax without disturbing the type.
The reveal is a grid track. A one-row grid animates grid-template-rows from 0fr to 1fr; the child has min-h-0 overflow-hidden so it collapses to nothing when closed:
<div class="grid grid-rows-[0fr] transition-[grid-template-rows] duration-300
group-data-live/card:grid-rows-[1fr] group-focus-visible/link:grid-rows-[1fr]
pointer-coarse:grid-rows-[1fr]">Why tilt the inner element. The <a> owns the focus ring; transforming it would repaint that ring every frame in some engines, so the transform lives on the article inside.
Make it yours
- Reveal speed: 250–400ms on
duration-300reads editorial. - Swap Newsreader for your publication's face — the tilt maths is typography-agnostic.
- Photo-led magazines: raise the cover to
scale-106. - Show the author row by default everywhere by deleting the
grid-rows-[0fr]utility.
Gotchas — read before shipping
- Cap article-card tilt near 5° — headlines shimmer and cheapen the brand beyond that.
- The 0fr→1fr trick needs
min-h-0on the row's child or it won't collapse. overflow-hiddenon the card flattens preserve-3d, so atranslate-zon the cover does nothing here — scale does the lifting.
Browser support
| Chrome | Safari | Firefox | Edge |
|---|---|---|---|
| 111+ | 16.4+ | 128+ | 111+ |
Animating grid-template-rows is Chrome 107 / Safari 16 / Firefox 66; older engines snap open.