12 / 20
All 20 in 20 Tailwind CSS Image Hover Effects
- 01Tailwind Image Hover Zoom In Card
- 02Tailwind Hover Reveal Text Over Image
- 03Tailwind E-Commerce Product Image Swap on Hover
- 04Tailwind Grid Image Blur Overlay Text Hover
- 05Tailwind Card Slide Up Caption on Hover
- 06Tailwind Grayscale to Color Image Hover
- 07Tailwind Image Color Overlay Tint on Hover
- 08Tailwind Neumorphic Image Card Hover Effect
- 09Tailwind Glassmorphism Overlay on Image Hover
- 10Tailwind Glowing Neon Border Image Hover
- 11Tailwind Image Clip-Path Transition Hover
- 12Tailwind Image Tilt 3D Perspective Hover
- 13Tailwind Shining Light Glare Image Hover
- 14Tailwind Image Split Transition Hover
- 15Tailwind Image Ripple Animation Hover
- 16Tailwind Circular Team Profile Avatar Hover
- 17Tailwind Fullscreen Background Image Hover
- 18Tailwind Masonry Gallery Zoom Hover
- 19Tailwind Image Icon Reveal Center Hover
- 20Tailwind Image Card Flip 3D Animation Hover
Tailwind Image Tilt 3D Perspective Hover
The pointer-tracked tilt, with the split the port makes possible: JavaScript writes four numbers into custom properties, and every piece of geometry stays in the markup. [transform:perspective(800px)_rotateX(var(--rx))_rotateY(var(--ry))_scale3d(var(--s),var(--s),var(--s))] is one utility reading live values — the script never touches a class or a style string.
Untitled UI Pro: 5,000+ React + Tailwind CSS v4 components · from $349 one-time
Untitled UI React · Affiliate link
The code
<section class="tih-12 w-full min-h-svh grid place-items-center bg-tih12-bg font-tih12-sans px-6 py-10 [perspective:1200px]" aria-label="3D tilt perspective hover demo">
<div class="grid w-full max-w-195 gap-5 grid-cols-1 sm:grid-cols-3">
<article style="--rx:0deg; --ry:0deg; --s:1; --mx:50%; --my:50%" class="group rounded-2xl overflow-hidden transform-3d will-change-transform [transform:perspective(800px)_rotateX(var(--rx))_rotateY(var(--ry))_scale3d(var(--s),var(--s),var(--s))] transition-[transform,box-shadow] duration-500 ease-tih12 data-tilting:transition-none hover:shadow-[0_30px_80px_oklch(0_0_0/0.6)] motion-reduce:[transform:none] motion-reduce:transition-none">
<div class="relative aspect-[3/4] grid place-items-center overflow-hidden bg-[image:url('https://picsum.photos/seed/tih12-mystic/600/800'),linear-gradient(135deg,oklch(0.17_0.07_285),oklch(0.28_0.13_290),oklch(0.55_0.24_295))] bg-cover bg-center bg-no-repeat">
<div class="absolute inset-0 pointer-events-none opacity-0 transition-opacity duration-300 group-hover:opacity-100 bg-radial-[circle_at_var(--mx)_var(--my),oklch(1_0_0/0.12),transparent_60%] motion-reduce:hidden"></div>
</div>
<div class="p-3.5 bg-[oklch(1_0_0/0.03)] backdrop-blur-[2px]">
<h3 class="text-[13px] font-bold text-tih12-text"><a href="#mystic" class="no-underline text-inherit rounded focus-visible:outline-2 focus-visible:outline-tih12-acc focus-visible:outline-offset-3">Mystic Artifacts</a></h3>
<p class="mt-0.75 text-[11px] text-tih12-muted">Interactive 3D collection</p>
</div>
</article>
<article style="--rx:0deg; --ry:0deg; --s:1; --mx:50%; --my:50%" class="group rounded-2xl overflow-hidden transform-3d will-change-transform [transform:perspective(800px)_rotateX(var(--rx))_rotateY(var(--ry))_scale3d(var(--s),var(--s),var(--s))] transition-[transform,box-shadow] duration-500 ease-tih12 data-tilting:transition-none hover:shadow-[0_30px_80px_oklch(0_0_0/0.6)] motion-reduce:[transform:none] motion-reduce:transition-none">
<div class="relative aspect-[3/4] grid place-items-center overflow-hidden bg-[image:url('https://picsum.photos/seed/tih12-botanical/600/800'),linear-gradient(135deg,oklch(0.2_0.05_165),oklch(0.35_0.09_163),oklch(0.7_0.15_163))] bg-cover bg-center bg-no-repeat">
<div class="absolute inset-0 pointer-events-none opacity-0 transition-opacity duration-300 group-hover:opacity-100 bg-radial-[circle_at_var(--mx)_var(--my),oklch(1_0_0/0.12),transparent_60%] motion-reduce:hidden"></div>
</div>
<div class="p-3.5 bg-[oklch(1_0_0/0.03)] backdrop-blur-[2px]">
<h3 class="text-[13px] font-bold text-tih12-text"><a href="#botanical" class="no-underline text-inherit rounded focus-visible:outline-2 focus-visible:outline-tih12-acc focus-visible:outline-offset-3">Botanical Studies</a></h3>
<p class="mt-0.75 text-[11px] text-tih12-muted">Flora & fauna series</p>
</div>
</article>
<article style="--rx:0deg; --ry:0deg; --s:1; --mx:50%; --my:50%" class="group rounded-2xl overflow-hidden transform-3d will-change-transform [transform:perspective(800px)_rotateX(var(--rx))_rotateY(var(--ry))_scale3d(var(--s),var(--s),var(--s))] transition-[transform,box-shadow] duration-500 ease-tih12 data-tilting:transition-none hover:shadow-[0_30px_80px_oklch(0_0_0/0.6)] motion-reduce:[transform:none] motion-reduce:transition-none">
<div class="relative aspect-[3/4] grid place-items-center overflow-hidden bg-[image:url('https://picsum.photos/seed/tih12-ocean/600/800'),linear-gradient(135deg,oklch(0.18_0.08_265),oklch(0.33_0.14_262),oklch(0.62_0.19_260))] bg-cover bg-center bg-no-repeat">
<div class="absolute inset-0 pointer-events-none opacity-0 transition-opacity duration-300 group-hover:opacity-100 bg-radial-[circle_at_var(--mx)_var(--my),oklch(1_0_0/0.12),transparent_60%] motion-reduce:hidden"></div>
</div>
<div class="p-3.5 bg-[oklch(1_0_0/0.03)] backdrop-blur-[2px]">
<h3 class="text-[13px] font-bold text-tih12-text"><a href="#ocean" class="no-underline text-inherit rounded focus-visible:outline-2 focus-visible:outline-tih12-acc focus-visible:outline-offset-3">Ocean Depths</a></h3>
<p class="mt-0.75 text-[11px] text-tih12-muted">Underwater photography</p>
</div>
</article>
</div>
</section><section class="tih-12 w-full min-h-svh grid place-items-center bg-tih12-bg font-tih12-sans px-6 py-10 [perspective:1200px]" aria-label="3D tilt perspective hover demo">
<div class="grid w-full max-w-195 gap-5 grid-cols-1 sm:grid-cols-3">
<article style="--rx:0deg; --ry:0deg; --s:1; --mx:50%; --my:50%" class="group rounded-2xl overflow-hidden transform-3d will-change-transform [transform:perspective(800px)_rotateX(var(--rx))_rotateY(var(--ry))_scale3d(var(--s),var(--s),var(--s))] transition-[transform,box-shadow] duration-500 ease-tih12 data-tilting:transition-none hover:shadow-[0_30px_80px_oklch(0_0_0/0.6)] motion-reduce:[transform:none] motion-reduce:transition-none">
<div class="relative aspect-[3/4] grid place-items-center overflow-hidden bg-[image:url('https://picsum.photos/seed/tih12-mystic/600/800'),linear-gradient(135deg,oklch(0.17_0.07_285),oklch(0.28_0.13_290),oklch(0.55_0.24_295))] bg-cover bg-center bg-no-repeat">
<div class="absolute inset-0 pointer-events-none opacity-0 transition-opacity duration-300 group-hover:opacity-100 bg-radial-[circle_at_var(--mx)_var(--my),oklch(1_0_0/0.12),transparent_60%] motion-reduce:hidden"></div>
</div>
<div class="p-3.5 bg-[oklch(1_0_0/0.03)] backdrop-blur-[2px]">
<h3 class="text-[13px] font-bold text-tih12-text"><a href="#mystic" class="no-underline text-inherit rounded focus-visible:outline-2 focus-visible:outline-tih12-acc focus-visible:outline-offset-3">Mystic Artifacts</a></h3>
<p class="mt-0.75 text-[11px] text-tih12-muted">Interactive 3D collection</p>
</div>
</article>
<article style="--rx:0deg; --ry:0deg; --s:1; --mx:50%; --my:50%" class="group rounded-2xl overflow-hidden transform-3d will-change-transform [transform:perspective(800px)_rotateX(var(--rx))_rotateY(var(--ry))_scale3d(var(--s),var(--s),var(--s))] transition-[transform,box-shadow] duration-500 ease-tih12 data-tilting:transition-none hover:shadow-[0_30px_80px_oklch(0_0_0/0.6)] motion-reduce:[transform:none] motion-reduce:transition-none">
<div class="relative aspect-[3/4] grid place-items-center overflow-hidden bg-[image:url('https://picsum.photos/seed/tih12-botanical/600/800'),linear-gradient(135deg,oklch(0.2_0.05_165),oklch(0.35_0.09_163),oklch(0.7_0.15_163))] bg-cover bg-center bg-no-repeat">
<div class="absolute inset-0 pointer-events-none opacity-0 transition-opacity duration-300 group-hover:opacity-100 bg-radial-[circle_at_var(--mx)_var(--my),oklch(1_0_0/0.12),transparent_60%] motion-reduce:hidden"></div>
</div>
<div class="p-3.5 bg-[oklch(1_0_0/0.03)] backdrop-blur-[2px]">
<h3 class="text-[13px] font-bold text-tih12-text"><a href="#botanical" class="no-underline text-inherit rounded focus-visible:outline-2 focus-visible:outline-tih12-acc focus-visible:outline-offset-3">Botanical Studies</a></h3>
<p class="mt-0.75 text-[11px] text-tih12-muted">Flora & fauna series</p>
</div>
</article>
<article style="--rx:0deg; --ry:0deg; --s:1; --mx:50%; --my:50%" class="group rounded-2xl overflow-hidden transform-3d will-change-transform [transform:perspective(800px)_rotateX(var(--rx))_rotateY(var(--ry))_scale3d(var(--s),var(--s),var(--s))] transition-[transform,box-shadow] duration-500 ease-tih12 data-tilting:transition-none hover:shadow-[0_30px_80px_oklch(0_0_0/0.6)] motion-reduce:[transform:none] motion-reduce:transition-none">
<div class="relative aspect-[3/4] grid place-items-center overflow-hidden bg-[image:url('https://picsum.photos/seed/tih12-ocean/600/800'),linear-gradient(135deg,oklch(0.18_0.08_265),oklch(0.33_0.14_262),oklch(0.62_0.19_260))] bg-cover bg-center bg-no-repeat">
<div class="absolute inset-0 pointer-events-none opacity-0 transition-opacity duration-300 group-hover:opacity-100 bg-radial-[circle_at_var(--mx)_var(--my),oklch(1_0_0/0.12),transparent_60%] motion-reduce:hidden"></div>
</div>
<div class="p-3.5 bg-[oklch(1_0_0/0.03)] backdrop-blur-[2px]">
<h3 class="text-[13px] font-bold text-tih12-text"><a href="#ocean" class="no-underline text-inherit rounded focus-visible:outline-2 focus-visible:outline-tih12-acc focus-visible:outline-offset-3">Ocean Depths</a></h3>
<p class="mt-0.75 text-[11px] text-tih12-muted">Underwater photography</p>
</div>
</article>
</div>
</section>@import "tailwindcss";
@theme {
--color-tih12-bg: #060a12;
--color-tih12-bg: oklch(0.11 0.015 265);
--color-tih12-text: #f1f5f9;
--color-tih12-text: oklch(0.96 0.005 250);
--color-tih12-muted: #64748b;
--color-tih12-muted: oklch(0.58 0.035 255);
--color-tih12-acc: #38bdf8;
--color-tih12-acc: oklch(0.75 0.13 230);
--font-tih12-sans: 'Space Grotesk', system-ui, sans-serif;
--ease-tih12: cubic-bezier(0.25, 0.46, 0.45, 0.94);
}
/* Root rule: the full-bleed contract, stated in CSS as well as in the markup's
w-full min-h-svh, so the stage is correctly sized before the browser build compiles. */
.tih-12 {
width: 100%;
min-height: 100svh;
}
.tih-12 ::selection {
background: oklch(0.75 0.13 230);
color: #000;
}@import "tailwindcss";
@theme {
--color-tih12-bg: #060a12;
--color-tih12-bg: oklch(0.11 0.015 265);
--color-tih12-text: #f1f5f9;
--color-tih12-text: oklch(0.96 0.005 250);
--color-tih12-muted: #64748b;
--color-tih12-muted: oklch(0.58 0.035 255);
--color-tih12-acc: #38bdf8;
--color-tih12-acc: oklch(0.75 0.13 230);
--font-tih12-sans: 'Space Grotesk', system-ui, sans-serif;
--ease-tih12: cubic-bezier(0.25, 0.46, 0.45, 0.94);
}
/* Root rule: the full-bleed contract, stated in CSS as well as in the markup's
w-full min-h-svh, so the stage is correctly sized before the browser build compiles. */
.tih-12 {
width: 100%;
min-height: 100svh;
}
.tih-12 ::selection {
background: oklch(0.75 0.13 230);
color: #000;
}(function () {
var INTENSITY = 18;
document.querySelectorAll('.tih-12 article').forEach(function (card) {
if (card.dataset.bound) return; card.dataset.bound = '1';
var pending = null, frame = 0;
function apply() {
frame = 0;
if (!pending) return;
var r = card.getBoundingClientRect(),
x = (pending.clientX - r.left) / r.width,
y = (pending.clientY - r.top) / r.height;
card.style.setProperty('--rx', ((y - 0.5) * -INTENSITY).toFixed(2) + 'deg');
card.style.setProperty('--ry', ((x - 0.5) * INTENSITY).toFixed(2) + 'deg');
card.style.setProperty('--s', '1.02');
card.style.setProperty('--mx', (x * 100).toFixed(1) + '%');
card.style.setProperty('--my', (y * 100).toFixed(1) + '%');
}
card.addEventListener('pointermove', function (e) {
card.setAttribute('data-tilting', '');
pending = e;
if (!frame) frame = requestAnimationFrame(apply);
});
card.addEventListener('pointerleave', function () {
card.removeAttribute('data-tilting');
pending = null;
card.style.setProperty('--rx', '0deg');
card.style.setProperty('--ry', '0deg');
card.style.setProperty('--s', '1');
});
});
})();(function () {
var INTENSITY = 18;
document.querySelectorAll('.tih-12 article').forEach(function (card) {
if (card.dataset.bound) return; card.dataset.bound = '1';
var pending = null, frame = 0;
function apply() {
frame = 0;
if (!pending) return;
var r = card.getBoundingClientRect(),
x = (pending.clientX - r.left) / r.width,
y = (pending.clientY - r.top) / r.height;
card.style.setProperty('--rx', ((y - 0.5) * -INTENSITY).toFixed(2) + 'deg');
card.style.setProperty('--ry', ((x - 0.5) * INTENSITY).toFixed(2) + 'deg');
card.style.setProperty('--s', '1.02');
card.style.setProperty('--mx', (x * 100).toFixed(1) + '%');
card.style.setProperty('--my', (y * 100).toFixed(1) + '%');
}
card.addEventListener('pointermove', function (e) {
card.setAttribute('data-tilting', '');
pending = e;
if (!frame) frame = requestAnimationFrame(apply);
});
card.addEventListener('pointerleave', function () {
card.removeAttribute('data-tilting');
pending = null;
card.style.setProperty('--rx', '0deg');
card.style.setProperty('--ry', '0deg');
card.style.setProperty('--s', '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-tih12-bg: #060a12;
--color-tih12-bg: oklch(0.11 0.015 265);
--color-tih12-text: #f1f5f9;
--color-tih12-text: oklch(0.96 0.005 250);
--color-tih12-muted: #64748b;
--color-tih12-muted: oklch(0.58 0.035 255);
--color-tih12-acc: #38bdf8;
--color-tih12-acc: oklch(0.75 0.13 230);
--font-tih12-sans: 'Space Grotesk', system-ui, sans-serif;
--ease-tih12: cubic-bezier(0.25, 0.46, 0.45, 0.94);
}
/* Root rule: the full-bleed contract, stated in CSS as well as in the markup's
w-full min-h-svh, so the stage is correctly sized before the browser build compiles. */
.tih-12 {
width: 100%;
min-height: 100svh;
}
.tih-12 ::selection {
background: oklch(0.75 0.13 230);
color: #000;
}@import "tailwindcss";
@theme {
--color-tih12-bg: #060a12;
--color-tih12-bg: oklch(0.11 0.015 265);
--color-tih12-text: #f1f5f9;
--color-tih12-text: oklch(0.96 0.005 250);
--color-tih12-muted: #64748b;
--color-tih12-muted: oklch(0.58 0.035 255);
--color-tih12-acc: #38bdf8;
--color-tih12-acc: oklch(0.75 0.13 230);
--font-tih12-sans: 'Space Grotesk', system-ui, sans-serif;
--ease-tih12: cubic-bezier(0.25, 0.46, 0.45, 0.94);
}
/* Root rule: the full-bleed contract, stated in CSS as well as in the markup's
w-full min-h-svh, so the stage is correctly sized before the browser build compiles. */
.tih-12 {
width: 100%;
min-height: 100svh;
}
.tih-12 ::selection {
background: oklch(0.75 0.13 230);
color: #000;
}<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;500;600;700&family=JetBrains+Mono:wght@400;500;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=Space+Grotesk:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap">What changed in the port
Which parts became utilities, which stayed CSS, and why.
- The biggest structural change in the collection: the JS no longer writes a
transformstring. It writes five numbers, and the pose — perspective distance, rotation axes, lift — lives in the class list where a designer can edit it. - The specular gradient recipe moves out of CSS into a
bg-radial-[circle_at_var(--mx)_var(--my),…]utility, still driven by the same two properties. - The CSS version's
setTimeoutthat clears an inline transition is replaced by adata-tiltingattribute and adata-tilting:transition-nonevariant — state in the DOM, styling in the markup, no timer to race. pointermovereplacesmousemove, so pen and touch work without the extratouchmovebranch the CSS version suggests adding.transform-3dandwill-change-transformare plain utilities;perspective:1200pxon the stage stays arbitrary, because a scene distance is not a design-scale value.motion-reduce:[transform:none]plusmotion-reduce:hiddenon the shine removes the effect rather than slowing it, and the script's writes become inert because nothing reads them.
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 Image Tilt 3D Perspective Hover
Source: https://codefronts.com/motion/tailwind-css-image-hover-effects/tailwind-image-tilt-3d-perspective-hover/
The pointer-tracked tilt, with the split the port makes possible: JavaScript writes four numbers into custom properties, and every piece of geometry stays in the markup. [transform:perspective(800px)_rotateX(var(--rx))_rotateY(var(--ry))_scale3d(var(--s),var(--s),var(--s))] is one utility reading live values — the script never touches a class or a style string.
## HTML
```html
<section class="tih-12 w-full min-h-svh grid place-items-center bg-tih12-bg font-tih12-sans px-6 py-10 [perspective:1200px]" aria-label="3D tilt perspective hover demo">
<div class="grid w-full max-w-195 gap-5 grid-cols-1 sm:grid-cols-3">
<article style="--rx:0deg; --ry:0deg; --s:1; --mx:50%; --my:50%" class="group rounded-2xl overflow-hidden transform-3d will-change-transform [transform:perspective(800px)_rotateX(var(--rx))_rotateY(var(--ry))_scale3d(var(--s),var(--s),var(--s))] transition-[transform,box-shadow] duration-500 ease-tih12 data-tilting:transition-none hover:shadow-[0_30px_80px_oklch(0_0_0/0.6)] motion-reduce:[transform:none] motion-reduce:transition-none">
<div class="relative aspect-[3/4] grid place-items-center overflow-hidden bg-[image:url('https://picsum.photos/seed/tih12-mystic/600/800'),linear-gradient(135deg,oklch(0.17_0.07_285),oklch(0.28_0.13_290),oklch(0.55_0.24_295))] bg-cover bg-center bg-no-repeat">
<div class="absolute inset-0 pointer-events-none opacity-0 transition-opacity duration-300 group-hover:opacity-100 bg-radial-[circle_at_var(--mx)_var(--my),oklch(1_0_0/0.12),transparent_60%] motion-reduce:hidden"></div>
</div>
<div class="p-3.5 bg-[oklch(1_0_0/0.03)] backdrop-blur-[2px]">
<h3 class="text-[13px] font-bold text-tih12-text"><a href="#mystic" class="no-underline text-inherit rounded focus-visible:outline-2 focus-visible:outline-tih12-acc focus-visible:outline-offset-3">Mystic Artifacts</a></h3>
<p class="mt-0.75 text-[11px] text-tih12-muted">Interactive 3D collection</p>
</div>
</article>
<article style="--rx:0deg; --ry:0deg; --s:1; --mx:50%; --my:50%" class="group rounded-2xl overflow-hidden transform-3d will-change-transform [transform:perspective(800px)_rotateX(var(--rx))_rotateY(var(--ry))_scale3d(var(--s),var(--s),var(--s))] transition-[transform,box-shadow] duration-500 ease-tih12 data-tilting:transition-none hover:shadow-[0_30px_80px_oklch(0_0_0/0.6)] motion-reduce:[transform:none] motion-reduce:transition-none">
<div class="relative aspect-[3/4] grid place-items-center overflow-hidden bg-[image:url('https://picsum.photos/seed/tih12-botanical/600/800'),linear-gradient(135deg,oklch(0.2_0.05_165),oklch(0.35_0.09_163),oklch(0.7_0.15_163))] bg-cover bg-center bg-no-repeat">
<div class="absolute inset-0 pointer-events-none opacity-0 transition-opacity duration-300 group-hover:opacity-100 bg-radial-[circle_at_var(--mx)_var(--my),oklch(1_0_0/0.12),transparent_60%] motion-reduce:hidden"></div>
</div>
<div class="p-3.5 bg-[oklch(1_0_0/0.03)] backdrop-blur-[2px]">
<h3 class="text-[13px] font-bold text-tih12-text"><a href="#botanical" class="no-underline text-inherit rounded focus-visible:outline-2 focus-visible:outline-tih12-acc focus-visible:outline-offset-3">Botanical Studies</a></h3>
<p class="mt-0.75 text-[11px] text-tih12-muted">Flora & fauna series</p>
</div>
</article>
<article style="--rx:0deg; --ry:0deg; --s:1; --mx:50%; --my:50%" class="group rounded-2xl overflow-hidden transform-3d will-change-transform [transform:perspective(800px)_rotateX(var(--rx))_rotateY(var(--ry))_scale3d(var(--s),var(--s),var(--s))] transition-[transform,box-shadow] duration-500 ease-tih12 data-tilting:transition-none hover:shadow-[0_30px_80px_oklch(0_0_0/0.6)] motion-reduce:[transform:none] motion-reduce:transition-none">
<div class="relative aspect-[3/4] grid place-items-center overflow-hidden bg-[image:url('https://picsum.photos/seed/tih12-ocean/600/800'),linear-gradient(135deg,oklch(0.18_0.08_265),oklch(0.33_0.14_262),oklch(0.62_0.19_260))] bg-cover bg-center bg-no-repeat">
<div class="absolute inset-0 pointer-events-none opacity-0 transition-opacity duration-300 group-hover:opacity-100 bg-radial-[circle_at_var(--mx)_var(--my),oklch(1_0_0/0.12),transparent_60%] motion-reduce:hidden"></div>
</div>
<div class="p-3.5 bg-[oklch(1_0_0/0.03)] backdrop-blur-[2px]">
<h3 class="text-[13px] font-bold text-tih12-text"><a href="#ocean" class="no-underline text-inherit rounded focus-visible:outline-2 focus-visible:outline-tih12-acc focus-visible:outline-offset-3">Ocean Depths</a></h3>
<p class="mt-0.75 text-[11px] text-tih12-muted">Underwater photography</p>
</div>
</article>
</div>
</section>
```
## CSS
```css
@import "tailwindcss";
@theme {
--color-tih12-bg: #060a12;
--color-tih12-bg: oklch(0.11 0.015 265);
--color-tih12-text: #f1f5f9;
--color-tih12-text: oklch(0.96 0.005 250);
--color-tih12-muted: #64748b;
--color-tih12-muted: oklch(0.58 0.035 255);
--color-tih12-acc: #38bdf8;
--color-tih12-acc: oklch(0.75 0.13 230);
--font-tih12-sans: 'Space Grotesk', system-ui, sans-serif;
--ease-tih12: cubic-bezier(0.25, 0.46, 0.45, 0.94);
}
/* Root rule: the full-bleed contract, stated in CSS as well as in the markup's
w-full min-h-svh, so the stage is correctly sized before the browser build compiles. */
.tih-12 {
width: 100%;
min-height: 100svh;
}
.tih-12 ::selection {
background: oklch(0.75 0.13 230);
color: #000;
}
```
## JavaScript
```js
(function () {
var INTENSITY = 18;
document.querySelectorAll('.tih-12 article').forEach(function (card) {
if (card.dataset.bound) return; card.dataset.bound = '1';
var pending = null, frame = 0;
function apply() {
frame = 0;
if (!pending) return;
var r = card.getBoundingClientRect(),
x = (pending.clientX - r.left) / r.width,
y = (pending.clientY - r.top) / r.height;
card.style.setProperty('--rx', ((y - 0.5) * -INTENSITY).toFixed(2) + 'deg');
card.style.setProperty('--ry', ((x - 0.5) * INTENSITY).toFixed(2) + 'deg');
card.style.setProperty('--s', '1.02');
card.style.setProperty('--mx', (x * 100).toFixed(1) + '%');
card.style.setProperty('--my', (y * 100).toFixed(1) + '%');
}
card.addEventListener('pointermove', function (e) {
card.setAttribute('data-tilting', '');
pending = e;
if (!frame) frame = requestAnimationFrame(apply);
});
card.addEventListener('pointerleave', function () {
card.removeAttribute('data-tilting');
pending = null;
card.style.setProperty('--rx', '0deg');
card.style.setProperty('--ry', '0deg');
card.style.setProperty('--s', '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 Image Tilt 3D Perspective Hover
Source: https://codefronts.com/motion/tailwind-css-image-hover-effects/tailwind-image-tilt-3d-perspective-hover/
The pointer-tracked tilt, with the split the port makes possible: JavaScript writes four numbers into custom properties, and every piece of geometry stays in the markup. [transform:perspective(800px)_rotateX(var(--rx))_rotateY(var(--ry))_scale3d(var(--s),var(--s),var(--s))] is one utility reading live values — the script never touches a class or a style string.
## HTML
```html
<section class="tih-12 w-full min-h-svh grid place-items-center bg-tih12-bg font-tih12-sans px-6 py-10 [perspective:1200px]" aria-label="3D tilt perspective hover demo">
<div class="grid w-full max-w-195 gap-5 grid-cols-1 sm:grid-cols-3">
<article style="--rx:0deg; --ry:0deg; --s:1; --mx:50%; --my:50%" class="group rounded-2xl overflow-hidden transform-3d will-change-transform [transform:perspective(800px)_rotateX(var(--rx))_rotateY(var(--ry))_scale3d(var(--s),var(--s),var(--s))] transition-[transform,box-shadow] duration-500 ease-tih12 data-tilting:transition-none hover:shadow-[0_30px_80px_oklch(0_0_0/0.6)] motion-reduce:[transform:none] motion-reduce:transition-none">
<div class="relative aspect-[3/4] grid place-items-center overflow-hidden bg-[image:url('https://picsum.photos/seed/tih12-mystic/600/800'),linear-gradient(135deg,oklch(0.17_0.07_285),oklch(0.28_0.13_290),oklch(0.55_0.24_295))] bg-cover bg-center bg-no-repeat">
<div class="absolute inset-0 pointer-events-none opacity-0 transition-opacity duration-300 group-hover:opacity-100 bg-radial-[circle_at_var(--mx)_var(--my),oklch(1_0_0/0.12),transparent_60%] motion-reduce:hidden"></div>
</div>
<div class="p-3.5 bg-[oklch(1_0_0/0.03)] backdrop-blur-[2px]">
<h3 class="text-[13px] font-bold text-tih12-text"><a href="#mystic" class="no-underline text-inherit rounded focus-visible:outline-2 focus-visible:outline-tih12-acc focus-visible:outline-offset-3">Mystic Artifacts</a></h3>
<p class="mt-0.75 text-[11px] text-tih12-muted">Interactive 3D collection</p>
</div>
</article>
<article style="--rx:0deg; --ry:0deg; --s:1; --mx:50%; --my:50%" class="group rounded-2xl overflow-hidden transform-3d will-change-transform [transform:perspective(800px)_rotateX(var(--rx))_rotateY(var(--ry))_scale3d(var(--s),var(--s),var(--s))] transition-[transform,box-shadow] duration-500 ease-tih12 data-tilting:transition-none hover:shadow-[0_30px_80px_oklch(0_0_0/0.6)] motion-reduce:[transform:none] motion-reduce:transition-none">
<div class="relative aspect-[3/4] grid place-items-center overflow-hidden bg-[image:url('https://picsum.photos/seed/tih12-botanical/600/800'),linear-gradient(135deg,oklch(0.2_0.05_165),oklch(0.35_0.09_163),oklch(0.7_0.15_163))] bg-cover bg-center bg-no-repeat">
<div class="absolute inset-0 pointer-events-none opacity-0 transition-opacity duration-300 group-hover:opacity-100 bg-radial-[circle_at_var(--mx)_var(--my),oklch(1_0_0/0.12),transparent_60%] motion-reduce:hidden"></div>
</div>
<div class="p-3.5 bg-[oklch(1_0_0/0.03)] backdrop-blur-[2px]">
<h3 class="text-[13px] font-bold text-tih12-text"><a href="#botanical" class="no-underline text-inherit rounded focus-visible:outline-2 focus-visible:outline-tih12-acc focus-visible:outline-offset-3">Botanical Studies</a></h3>
<p class="mt-0.75 text-[11px] text-tih12-muted">Flora & fauna series</p>
</div>
</article>
<article style="--rx:0deg; --ry:0deg; --s:1; --mx:50%; --my:50%" class="group rounded-2xl overflow-hidden transform-3d will-change-transform [transform:perspective(800px)_rotateX(var(--rx))_rotateY(var(--ry))_scale3d(var(--s),var(--s),var(--s))] transition-[transform,box-shadow] duration-500 ease-tih12 data-tilting:transition-none hover:shadow-[0_30px_80px_oklch(0_0_0/0.6)] motion-reduce:[transform:none] motion-reduce:transition-none">
<div class="relative aspect-[3/4] grid place-items-center overflow-hidden bg-[image:url('https://picsum.photos/seed/tih12-ocean/600/800'),linear-gradient(135deg,oklch(0.18_0.08_265),oklch(0.33_0.14_262),oklch(0.62_0.19_260))] bg-cover bg-center bg-no-repeat">
<div class="absolute inset-0 pointer-events-none opacity-0 transition-opacity duration-300 group-hover:opacity-100 bg-radial-[circle_at_var(--mx)_var(--my),oklch(1_0_0/0.12),transparent_60%] motion-reduce:hidden"></div>
</div>
<div class="p-3.5 bg-[oklch(1_0_0/0.03)] backdrop-blur-[2px]">
<h3 class="text-[13px] font-bold text-tih12-text"><a href="#ocean" class="no-underline text-inherit rounded focus-visible:outline-2 focus-visible:outline-tih12-acc focus-visible:outline-offset-3">Ocean Depths</a></h3>
<p class="mt-0.75 text-[11px] text-tih12-muted">Underwater photography</p>
</div>
</article>
</div>
</section>
```
## CSS
```css
@import "tailwindcss";
@theme {
--color-tih12-bg: #060a12;
--color-tih12-bg: oklch(0.11 0.015 265);
--color-tih12-text: #f1f5f9;
--color-tih12-text: oklch(0.96 0.005 250);
--color-tih12-muted: #64748b;
--color-tih12-muted: oklch(0.58 0.035 255);
--color-tih12-acc: #38bdf8;
--color-tih12-acc: oklch(0.75 0.13 230);
--font-tih12-sans: 'Space Grotesk', system-ui, sans-serif;
--ease-tih12: cubic-bezier(0.25, 0.46, 0.45, 0.94);
}
/* Root rule: the full-bleed contract, stated in CSS as well as in the markup's
w-full min-h-svh, so the stage is correctly sized before the browser build compiles. */
.tih-12 {
width: 100%;
min-height: 100svh;
}
.tih-12 ::selection {
background: oklch(0.75 0.13 230);
color: #000;
}
```
## JavaScript
```js
(function () {
var INTENSITY = 18;
document.querySelectorAll('.tih-12 article').forEach(function (card) {
if (card.dataset.bound) return; card.dataset.bound = '1';
var pending = null, frame = 0;
function apply() {
frame = 0;
if (!pending) return;
var r = card.getBoundingClientRect(),
x = (pending.clientX - r.left) / r.width,
y = (pending.clientY - r.top) / r.height;
card.style.setProperty('--rx', ((y - 0.5) * -INTENSITY).toFixed(2) + 'deg');
card.style.setProperty('--ry', ((x - 0.5) * INTENSITY).toFixed(2) + 'deg');
card.style.setProperty('--s', '1.02');
card.style.setProperty('--mx', (x * 100).toFixed(1) + '%');
card.style.setProperty('--my', (y * 100).toFixed(1) + '%');
}
card.addEventListener('pointermove', function (e) {
card.setAttribute('data-tilting', '');
pending = e;
if (!frame) frame = requestAnimationFrame(apply);
});
card.addEventListener('pointerleave', function () {
card.removeAttribute('data-tilting');
pending = null;
card.style.setProperty('--rx', '0deg');
card.style.setProperty('--ry', '0deg');
card.style.setProperty('--s', '1');
});
});
})();
```How this works
The script writes numbers; the markup writes geometry.
card.style.setProperty('--rx', rx + 'deg');
card.style.setProperty('--ry', ry + 'deg');
card.style.setProperty('--s', 1.02);Compare the CSS version, which assembles a whole transform string in JS. There, changing the perspective distance or adding a translate means editing JavaScript. Here the pose lives in the class list and the script is unaware of it — restyling never means reopening the controller.
The shine needs no JS styling either. Two more properties (--mx, --my) feed a bg-radial-[…] utility, so the highlight position is data and the gradient recipe is markup.
Spring-back is an attribute. While the pointer moves, data-tilting is set and the card carries data-tilting:transition-none so it tracks the cursor with zero lag. On mouseleave the attribute is removed, the base transition-transform duration-500 applies, and the card eases back to flat. No setTimeout clearing inline transitions, which is how the CSS version does it.
rAF-throttled. Pointer events fire faster than frames; the handler stores the event and writes once per frame.
Make it yours
- Tilt intensity: the
INTENSITYconstant (18). 10–25 is the natural range. - Perspective depth: the
800pxin the transform utility — markup, not script. - Lift: the
--svalue the script sets, or hard-codescale3d(1.02,1.02,1.02)if you never want it to vary. - Shine strength: the
oklch(1_0_0/0.12)stop inside the radial utility. - Touch support: the script already listens for
pointermove, so pen and touch drive the same path.
Gotchas — read before shipping
transform-3dbelongs on the card, not the stage. On the stage, children flatten and the tilt loses its depth.- Keep
overflow-hiddenand the 3D transform on different elements — Safari clips perspective incorrectly when they share one. - Set the transform once per frame. Writing three custom properties per
pointermovewithout rAF throttling is a measurable INP regression on a long grid. - The arbitrary transform must be one unbroken token: underscores for spaces, and
var(--rx)typed inline. - Tilt is decoration. The card's link is what is focusable, and
motion-reduce:removes the effect entirely — vestibular triggers are not negotiable. - The browser CDN build compiles in the page and is for prototyping only. Ship the CLI or a plugin build.
Browser support
| Chrome | Safari | Firefox | Edge |
|---|---|---|---|
| 111+ | 16.4+ | 128+ | 111+ |
CSS 3D transforms are broadly supported and the effect degrades to a flat card with JS disabled; the floor quoted is Tailwind v4's own generated sheet.