15 / 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)
Pure Tailwind 3D Tilt Card — No JavaScript (:has() Hover Zones)
Zero JavaScript, zero listeners: nine invisible hover zones tile the card, and has-[[data-z=c]:hover]:[--rx:var(--tx)] maps whichever zone the cursor occupies to a tilt pose. Eased transitions blend the nine poses into motion that feels continuous. New in the port: the glare glides between zones because --ttc15-mx/--ttc15-my are registered with @property and transitioned.
Untitled UI Pro: 5,000+ React + Tailwind CSS v4 components · from $349 one-time
Untitled UI React · Affiliate link
The code
<section class="ttc-15 w-full min-h-svh grid place-items-center bg-ttc15-bg bg-[radial-gradient(80%_90%_at_25%_15%,var(--color-ttc15-lobe),transparent_60%)] px-6 py-14 font-ttc15-sans text-white" aria-label="Pure Tailwind no-JavaScript 3D tilt card demo">
<div class="grid w-full justify-items-center gap-8">
<article class="group/card relative grid aspect-[31/24] w-full max-w-[20rem] grid-cols-3 grid-rows-3 rounded-[22px] border border-white/12 bg-linear-165 from-ttc15-card1 to-ttc15-card2 shadow-[0_28px_58px_-28px_oklch(0_0_0/0.8)] [--tx:10deg] [--ty:10deg] [transform:perspective(1000px)_rotateX(var(--rx,0deg))_rotateY(var(--ry,0deg))] [transition:transform_0.35s_ease,--ttc15-mx_0.35s_ease,--ttc15-my_0.35s_ease]
has-[[data-z=a]:hover]:[--rx:var(--tx)] has-[[data-z=a]:hover]:[--ry:calc(var(--ty)*-1)] has-[[data-z=a]:hover]:[--ttc15-mx:16%] has-[[data-z=a]:hover]:[--ttc15-my:16%]
has-[[data-z=b]:hover]:[--rx:var(--tx)] has-[[data-z=b]:hover]:[--ry:0deg] has-[[data-z=b]:hover]:[--ttc15-mx:50%] has-[[data-z=b]:hover]:[--ttc15-my:16%]
has-[[data-z=c]:hover]:[--rx:var(--tx)] has-[[data-z=c]:hover]:[--ry:var(--ty)] has-[[data-z=c]:hover]:[--ttc15-mx:84%] has-[[data-z=c]:hover]:[--ttc15-my:16%]
has-[[data-z=d]:hover]:[--rx:0deg] has-[[data-z=d]:hover]:[--ry:calc(var(--ty)*-1)] has-[[data-z=d]:hover]:[--ttc15-mx:16%] has-[[data-z=d]:hover]:[--ttc15-my:50%]
has-[[data-z=e]:hover]:[--rx:0deg] has-[[data-z=e]:hover]:[--ry:0deg] has-[[data-z=e]:hover]:[--ttc15-mx:50%] has-[[data-z=e]:hover]:[--ttc15-my:50%]
has-[[data-z=f]:hover]:[--rx:0deg] has-[[data-z=f]:hover]:[--ry:var(--ty)] has-[[data-z=f]:hover]:[--ttc15-mx:84%] has-[[data-z=f]:hover]:[--ttc15-my:50%]
has-[[data-z=g]:hover]:[--rx:calc(var(--tx)*-1)] has-[[data-z=g]:hover]:[--ry:calc(var(--ty)*-1)] has-[[data-z=g]:hover]:[--ttc15-mx:16%] has-[[data-z=g]:hover]:[--ttc15-my:84%]
has-[[data-z=h]:hover]:[--rx:calc(var(--tx)*-1)] has-[[data-z=h]:hover]:[--ry:0deg] has-[[data-z=h]:hover]:[--ttc15-mx:50%] has-[[data-z=h]:hover]:[--ttc15-my:84%]
has-[[data-z=i]:hover]:[--rx:calc(var(--tx)*-1)] has-[[data-z=i]:hover]:[--ry:var(--ty)] has-[[data-z=i]:hover]:[--ttc15-mx:84%] has-[[data-z=i]:hover]:[--ttc15-my:84%]
has-[:focus-visible]:[--rx:3deg] [@media(hover:none)]:[transform:none] motion-reduce:[transform:none] motion-reduce:[transition:none]">
<i data-z="a" class="z-30"></i><i data-z="b" class="z-30"></i><i data-z="c" class="z-30"></i>
<i data-z="d" class="z-30"></i><i data-z="e" class="z-30"></i><i data-z="f" class="z-30"></i>
<i data-z="g" class="z-30"></i><i data-z="h" class="z-30"></i><i data-z="i" class="z-30"></i>
<div aria-hidden="true" class="pointer-events-none absolute inset-0 z-10 rounded-[inherit] bg-[radial-gradient(240px_circle_at_var(--ttc15-mx)_var(--ttc15-my),oklch(1_0_0/0.17),transparent_62%)] opacity-0 transition-opacity duration-350 group-hover/card:opacity-100 motion-reduce:hidden"></div>
<div class="pointer-events-none absolute inset-0 z-20 flex flex-col p-7">
<p class="font-ttc15-mono text-[10.5px] font-bold tracking-[0.2em] text-ttc15-acc">0 KB OF JAVASCRIPT</p>
<h3 class="mt-3 text-[30px] leading-none font-bold tracking-[-0.02em]">Pure CSS Tilt</h3>
<p class="mt-3 text-[14px] leading-[1.55] text-ttc15-mut text-pretty">Nine invisible hover zones + <code class="font-ttc15-mono text-[12.5px] text-white">:has()</code> — no listeners, no library, no main-thread work.</p>
</div>
<a href="#" class="pointer-events-auto absolute bottom-7 left-7 z-40 inline-flex min-h-11 items-center rounded-full bg-ttc15-acc px-5 text-[13px] font-semibold text-ttc15-bg no-underline transition-[filter] hover:brightness-110 focus-visible:outline-3 focus-visible:outline-offset-3 focus-visible:outline-white">Read the technique</a>
</article>
<p aria-hidden="true" class="rounded-full border border-white/12 bg-white/5 px-3.5 py-2 font-ttc15-mono text-[11.5px] text-ttc15-mut">has-[[data-z=c]:hover]:[--rx:var(--tx)] · @property glare</p>
</div>
</section><section class="ttc-15 w-full min-h-svh grid place-items-center bg-ttc15-bg bg-[radial-gradient(80%_90%_at_25%_15%,var(--color-ttc15-lobe),transparent_60%)] px-6 py-14 font-ttc15-sans text-white" aria-label="Pure Tailwind no-JavaScript 3D tilt card demo">
<div class="grid w-full justify-items-center gap-8">
<article class="group/card relative grid aspect-[31/24] w-full max-w-[20rem] grid-cols-3 grid-rows-3 rounded-[22px] border border-white/12 bg-linear-165 from-ttc15-card1 to-ttc15-card2 shadow-[0_28px_58px_-28px_oklch(0_0_0/0.8)] [--tx:10deg] [--ty:10deg] [transform:perspective(1000px)_rotateX(var(--rx,0deg))_rotateY(var(--ry,0deg))] [transition:transform_0.35s_ease,--ttc15-mx_0.35s_ease,--ttc15-my_0.35s_ease]
has-[[data-z=a]:hover]:[--rx:var(--tx)] has-[[data-z=a]:hover]:[--ry:calc(var(--ty)*-1)] has-[[data-z=a]:hover]:[--ttc15-mx:16%] has-[[data-z=a]:hover]:[--ttc15-my:16%]
has-[[data-z=b]:hover]:[--rx:var(--tx)] has-[[data-z=b]:hover]:[--ry:0deg] has-[[data-z=b]:hover]:[--ttc15-mx:50%] has-[[data-z=b]:hover]:[--ttc15-my:16%]
has-[[data-z=c]:hover]:[--rx:var(--tx)] has-[[data-z=c]:hover]:[--ry:var(--ty)] has-[[data-z=c]:hover]:[--ttc15-mx:84%] has-[[data-z=c]:hover]:[--ttc15-my:16%]
has-[[data-z=d]:hover]:[--rx:0deg] has-[[data-z=d]:hover]:[--ry:calc(var(--ty)*-1)] has-[[data-z=d]:hover]:[--ttc15-mx:16%] has-[[data-z=d]:hover]:[--ttc15-my:50%]
has-[[data-z=e]:hover]:[--rx:0deg] has-[[data-z=e]:hover]:[--ry:0deg] has-[[data-z=e]:hover]:[--ttc15-mx:50%] has-[[data-z=e]:hover]:[--ttc15-my:50%]
has-[[data-z=f]:hover]:[--rx:0deg] has-[[data-z=f]:hover]:[--ry:var(--ty)] has-[[data-z=f]:hover]:[--ttc15-mx:84%] has-[[data-z=f]:hover]:[--ttc15-my:50%]
has-[[data-z=g]:hover]:[--rx:calc(var(--tx)*-1)] has-[[data-z=g]:hover]:[--ry:calc(var(--ty)*-1)] has-[[data-z=g]:hover]:[--ttc15-mx:16%] has-[[data-z=g]:hover]:[--ttc15-my:84%]
has-[[data-z=h]:hover]:[--rx:calc(var(--tx)*-1)] has-[[data-z=h]:hover]:[--ry:0deg] has-[[data-z=h]:hover]:[--ttc15-mx:50%] has-[[data-z=h]:hover]:[--ttc15-my:84%]
has-[[data-z=i]:hover]:[--rx:calc(var(--tx)*-1)] has-[[data-z=i]:hover]:[--ry:var(--ty)] has-[[data-z=i]:hover]:[--ttc15-mx:84%] has-[[data-z=i]:hover]:[--ttc15-my:84%]
has-[:focus-visible]:[--rx:3deg] [@media(hover:none)]:[transform:none] motion-reduce:[transform:none] motion-reduce:[transition:none]">
<i data-z="a" class="z-30"></i><i data-z="b" class="z-30"></i><i data-z="c" class="z-30"></i>
<i data-z="d" class="z-30"></i><i data-z="e" class="z-30"></i><i data-z="f" class="z-30"></i>
<i data-z="g" class="z-30"></i><i data-z="h" class="z-30"></i><i data-z="i" class="z-30"></i>
<div aria-hidden="true" class="pointer-events-none absolute inset-0 z-10 rounded-[inherit] bg-[radial-gradient(240px_circle_at_var(--ttc15-mx)_var(--ttc15-my),oklch(1_0_0/0.17),transparent_62%)] opacity-0 transition-opacity duration-350 group-hover/card:opacity-100 motion-reduce:hidden"></div>
<div class="pointer-events-none absolute inset-0 z-20 flex flex-col p-7">
<p class="font-ttc15-mono text-[10.5px] font-bold tracking-[0.2em] text-ttc15-acc">0 KB OF JAVASCRIPT</p>
<h3 class="mt-3 text-[30px] leading-none font-bold tracking-[-0.02em]">Pure CSS Tilt</h3>
<p class="mt-3 text-[14px] leading-[1.55] text-ttc15-mut text-pretty">Nine invisible hover zones + <code class="font-ttc15-mono text-[12.5px] text-white">:has()</code> — no listeners, no library, no main-thread work.</p>
</div>
<a href="#" class="pointer-events-auto absolute bottom-7 left-7 z-40 inline-flex min-h-11 items-center rounded-full bg-ttc15-acc px-5 text-[13px] font-semibold text-ttc15-bg no-underline transition-[filter] hover:brightness-110 focus-visible:outline-3 focus-visible:outline-offset-3 focus-visible:outline-white">Read the technique</a>
</article>
<p aria-hidden="true" class="rounded-full border border-white/12 bg-white/5 px-3.5 py-2 font-ttc15-mono text-[11.5px] text-ttc15-mut">has-[[data-z=c]:hover]:[--rx:var(--tx)] · @property glare</p>
</div>
</section>@import "tailwindcss";
@theme {
--color-ttc15-bg: #0e0d15;
--color-ttc15-bg: oklch(0.15 0.015 280);
--color-ttc15-lobe: #3a3014cc;
--color-ttc15-lobe: oklch(0.26 0.05 85 / 0.8);
--color-ttc15-card1: #2a2619;
--color-ttc15-card1: oklch(0.22 0.03 85);
--color-ttc15-card2: #16141d;
--color-ttc15-card2: oklch(0.16 0.02 280);
--color-ttc15-acc: #f0c14b;
--color-ttc15-acc: oklch(0.83 0.14 85);
--color-ttc15-mut: #c9c5cf;
--color-ttc15-mut: oklch(0.83 0.015 290);
--font-ttc15-sans: 'Space Grotesk', system-ui, sans-serif;
--font-ttc15-mono: 'JetBrains Mono', ui-monospace, monospace;
}
/* Registered so the glare centre INTERPOLATES between zones instead of jumping — an at-rule, so it stays CSS. */
@property --ttc15-mx {
syntax: "<percentage>";
inherits: true;
initial-value: 50%;
}
@property --ttc15-my {
syntax: "<percentage>";
inherits: true;
initial-value: 50%;
}
/* 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-15 {
width: 100%;
min-height: 100svh;
}@import "tailwindcss";
@theme {
--color-ttc15-bg: #0e0d15;
--color-ttc15-bg: oklch(0.15 0.015 280);
--color-ttc15-lobe: #3a3014cc;
--color-ttc15-lobe: oklch(0.26 0.05 85 / 0.8);
--color-ttc15-card1: #2a2619;
--color-ttc15-card1: oklch(0.22 0.03 85);
--color-ttc15-card2: #16141d;
--color-ttc15-card2: oklch(0.16 0.02 280);
--color-ttc15-acc: #f0c14b;
--color-ttc15-acc: oklch(0.83 0.14 85);
--color-ttc15-mut: #c9c5cf;
--color-ttc15-mut: oklch(0.83 0.015 290);
--font-ttc15-sans: 'Space Grotesk', system-ui, sans-serif;
--font-ttc15-mono: 'JetBrains Mono', ui-monospace, monospace;
}
/* Registered so the glare centre INTERPOLATES between zones instead of jumping — an at-rule, so it stays CSS. */
@property --ttc15-mx {
syntax: "<percentage>";
inherits: true;
initial-value: 50%;
}
@property --ttc15-my {
syntax: "<percentage>";
inherits: true;
initial-value: 50%;
}
/* 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-15 {
width: 100%;
min-height: 100svh;
}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-ttc15-bg: #0e0d15;
--color-ttc15-bg: oklch(0.15 0.015 280);
--color-ttc15-lobe: #3a3014cc;
--color-ttc15-lobe: oklch(0.26 0.05 85 / 0.8);
--color-ttc15-card1: #2a2619;
--color-ttc15-card1: oklch(0.22 0.03 85);
--color-ttc15-card2: #16141d;
--color-ttc15-card2: oklch(0.16 0.02 280);
--color-ttc15-acc: #f0c14b;
--color-ttc15-acc: oklch(0.83 0.14 85);
--color-ttc15-mut: #c9c5cf;
--color-ttc15-mut: oklch(0.83 0.015 290);
--font-ttc15-sans: 'Space Grotesk', system-ui, sans-serif;
--font-ttc15-mono: 'JetBrains Mono', ui-monospace, monospace;
}
/* Registered so the glare centre INTERPOLATES between zones instead of jumping — an at-rule, so it stays CSS. */
@property --ttc15-mx {
syntax: "<percentage>";
inherits: true;
initial-value: 50%;
}
@property --ttc15-my {
syntax: "<percentage>";
inherits: true;
initial-value: 50%;
}
/* 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-15 {
width: 100%;
min-height: 100svh;
}@import "tailwindcss";
@theme {
--color-ttc15-bg: #0e0d15;
--color-ttc15-bg: oklch(0.15 0.015 280);
--color-ttc15-lobe: #3a3014cc;
--color-ttc15-lobe: oklch(0.26 0.05 85 / 0.8);
--color-ttc15-card1: #2a2619;
--color-ttc15-card1: oklch(0.22 0.03 85);
--color-ttc15-card2: #16141d;
--color-ttc15-card2: oklch(0.16 0.02 280);
--color-ttc15-acc: #f0c14b;
--color-ttc15-acc: oklch(0.83 0.14 85);
--color-ttc15-mut: #c9c5cf;
--color-ttc15-mut: oklch(0.83 0.015 290);
--font-ttc15-sans: 'Space Grotesk', system-ui, sans-serif;
--font-ttc15-mono: 'JetBrains Mono', ui-monospace, monospace;
}
/* Registered so the glare centre INTERPOLATES between zones instead of jumping — an at-rule, so it stays CSS. */
@property --ttc15-mx {
syntax: "<percentage>";
inherits: true;
initial-value: 50%;
}
@property --ttc15-my {
syntax: "<percentage>";
inherits: true;
initial-value: 50%;
}
/* 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-15 {
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.
- Nine
:has()rules (36 declarations) became 36 arbitrary-variant utilities on the card; the zones switched fromnth-childtodata-zletters so the markup reads as a map. - The
@media (hover:none)and reduced-motion overrides became[@media(hover:none)]:[transform:none]andmotion-reduce:utilities. - UI upgrade from the CSS original: the glare now glides between zones —
@property-registered--ttc15-mx/--ttc15-myare listed in the card's transition, where the original's glare teleported. - UI upgrade: the CTA grew to 44px, the body copy rose from 65% white to
oklch(0.83), the deadtabindexwas removed, and a keyboard lift (has-[:focus-visible]:) replaced the:focus-withinrule.
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: Pure Tailwind 3D Tilt Card — No JavaScript (:has() Hover Zones)
Source: https://codefronts.com/components/tailwind-css-3d-tilt-hover-cards/pure-tailwind-3d-tilt-card-no-javascript/
Zero JavaScript, zero listeners: nine invisible hover zones tile the card, and has-[[data-z=c]:hover]:[--rx:var(--tx)] maps whichever zone the cursor occupies to a tilt pose. Eased transitions blend the nine poses into motion that feels continuous. New in the port: the glare glides between zones because --ttc15-mx/--ttc15-my are registered with @property and transitioned.
## HTML
```html
<section class="ttc-15 w-full min-h-svh grid place-items-center bg-ttc15-bg bg-[radial-gradient(80%_90%_at_25%_15%,var(--color-ttc15-lobe),transparent_60%)] px-6 py-14 font-ttc15-sans text-white" aria-label="Pure Tailwind no-JavaScript 3D tilt card demo">
<div class="grid w-full justify-items-center gap-8">
<article class="group/card relative grid aspect-[31/24] w-full max-w-[20rem] grid-cols-3 grid-rows-3 rounded-[22px] border border-white/12 bg-linear-165 from-ttc15-card1 to-ttc15-card2 shadow-[0_28px_58px_-28px_oklch(0_0_0/0.8)] [--tx:10deg] [--ty:10deg] [transform:perspective(1000px)_rotateX(var(--rx,0deg))_rotateY(var(--ry,0deg))] [transition:transform_0.35s_ease,--ttc15-mx_0.35s_ease,--ttc15-my_0.35s_ease]
has-[[data-z=a]:hover]:[--rx:var(--tx)] has-[[data-z=a]:hover]:[--ry:calc(var(--ty)*-1)] has-[[data-z=a]:hover]:[--ttc15-mx:16%] has-[[data-z=a]:hover]:[--ttc15-my:16%]
has-[[data-z=b]:hover]:[--rx:var(--tx)] has-[[data-z=b]:hover]:[--ry:0deg] has-[[data-z=b]:hover]:[--ttc15-mx:50%] has-[[data-z=b]:hover]:[--ttc15-my:16%]
has-[[data-z=c]:hover]:[--rx:var(--tx)] has-[[data-z=c]:hover]:[--ry:var(--ty)] has-[[data-z=c]:hover]:[--ttc15-mx:84%] has-[[data-z=c]:hover]:[--ttc15-my:16%]
has-[[data-z=d]:hover]:[--rx:0deg] has-[[data-z=d]:hover]:[--ry:calc(var(--ty)*-1)] has-[[data-z=d]:hover]:[--ttc15-mx:16%] has-[[data-z=d]:hover]:[--ttc15-my:50%]
has-[[data-z=e]:hover]:[--rx:0deg] has-[[data-z=e]:hover]:[--ry:0deg] has-[[data-z=e]:hover]:[--ttc15-mx:50%] has-[[data-z=e]:hover]:[--ttc15-my:50%]
has-[[data-z=f]:hover]:[--rx:0deg] has-[[data-z=f]:hover]:[--ry:var(--ty)] has-[[data-z=f]:hover]:[--ttc15-mx:84%] has-[[data-z=f]:hover]:[--ttc15-my:50%]
has-[[data-z=g]:hover]:[--rx:calc(var(--tx)*-1)] has-[[data-z=g]:hover]:[--ry:calc(var(--ty)*-1)] has-[[data-z=g]:hover]:[--ttc15-mx:16%] has-[[data-z=g]:hover]:[--ttc15-my:84%]
has-[[data-z=h]:hover]:[--rx:calc(var(--tx)*-1)] has-[[data-z=h]:hover]:[--ry:0deg] has-[[data-z=h]:hover]:[--ttc15-mx:50%] has-[[data-z=h]:hover]:[--ttc15-my:84%]
has-[[data-z=i]:hover]:[--rx:calc(var(--tx)*-1)] has-[[data-z=i]:hover]:[--ry:var(--ty)] has-[[data-z=i]:hover]:[--ttc15-mx:84%] has-[[data-z=i]:hover]:[--ttc15-my:84%]
has-[:focus-visible]:[--rx:3deg] [@media(hover:none)]:[transform:none] motion-reduce:[transform:none] motion-reduce:[transition:none]">
<i data-z="a" class="z-30"></i><i data-z="b" class="z-30"></i><i data-z="c" class="z-30"></i>
<i data-z="d" class="z-30"></i><i data-z="e" class="z-30"></i><i data-z="f" class="z-30"></i>
<i data-z="g" class="z-30"></i><i data-z="h" class="z-30"></i><i data-z="i" class="z-30"></i>
<div aria-hidden="true" class="pointer-events-none absolute inset-0 z-10 rounded-[inherit] bg-[radial-gradient(240px_circle_at_var(--ttc15-mx)_var(--ttc15-my),oklch(1_0_0/0.17),transparent_62%)] opacity-0 transition-opacity duration-350 group-hover/card:opacity-100 motion-reduce:hidden"></div>
<div class="pointer-events-none absolute inset-0 z-20 flex flex-col p-7">
<p class="font-ttc15-mono text-[10.5px] font-bold tracking-[0.2em] text-ttc15-acc">0 KB OF JAVASCRIPT</p>
<h3 class="mt-3 text-[30px] leading-none font-bold tracking-[-0.02em]">Pure CSS Tilt</h3>
<p class="mt-3 text-[14px] leading-[1.55] text-ttc15-mut text-pretty">Nine invisible hover zones + <code class="font-ttc15-mono text-[12.5px] text-white">:has()</code> — no listeners, no library, no main-thread work.</p>
</div>
<a href="#" class="pointer-events-auto absolute bottom-7 left-7 z-40 inline-flex min-h-11 items-center rounded-full bg-ttc15-acc px-5 text-[13px] font-semibold text-ttc15-bg no-underline transition-[filter] hover:brightness-110 focus-visible:outline-3 focus-visible:outline-offset-3 focus-visible:outline-white">Read the technique</a>
</article>
<p aria-hidden="true" class="rounded-full border border-white/12 bg-white/5 px-3.5 py-2 font-ttc15-mono text-[11.5px] text-ttc15-mut">has-[[data-z=c]:hover]:[--rx:var(--tx)] · @property glare</p>
</div>
</section>
```
## CSS
```css
@import "tailwindcss";
@theme {
--color-ttc15-bg: #0e0d15;
--color-ttc15-bg: oklch(0.15 0.015 280);
--color-ttc15-lobe: #3a3014cc;
--color-ttc15-lobe: oklch(0.26 0.05 85 / 0.8);
--color-ttc15-card1: #2a2619;
--color-ttc15-card1: oklch(0.22 0.03 85);
--color-ttc15-card2: #16141d;
--color-ttc15-card2: oklch(0.16 0.02 280);
--color-ttc15-acc: #f0c14b;
--color-ttc15-acc: oklch(0.83 0.14 85);
--color-ttc15-mut: #c9c5cf;
--color-ttc15-mut: oklch(0.83 0.015 290);
--font-ttc15-sans: 'Space Grotesk', system-ui, sans-serif;
--font-ttc15-mono: 'JetBrains Mono', ui-monospace, monospace;
}
/* Registered so the glare centre INTERPOLATES between zones instead of jumping — an at-rule, so it stays CSS. */
@property --ttc15-mx {
syntax: "<percentage>";
inherits: true;
initial-value: 50%;
}
@property --ttc15-my {
syntax: "<percentage>";
inherits: true;
initial-value: 50%;
}
/* 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-15 {
width: 100%;
min-height: 100svh;
}
```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: Pure Tailwind 3D Tilt Card — No JavaScript (:has() Hover Zones)
Source: https://codefronts.com/components/tailwind-css-3d-tilt-hover-cards/pure-tailwind-3d-tilt-card-no-javascript/
Zero JavaScript, zero listeners: nine invisible hover zones tile the card, and has-[[data-z=c]:hover]:[--rx:var(--tx)] maps whichever zone the cursor occupies to a tilt pose. Eased transitions blend the nine poses into motion that feels continuous. New in the port: the glare glides between zones because --ttc15-mx/--ttc15-my are registered with @property and transitioned.
## HTML
```html
<section class="ttc-15 w-full min-h-svh grid place-items-center bg-ttc15-bg bg-[radial-gradient(80%_90%_at_25%_15%,var(--color-ttc15-lobe),transparent_60%)] px-6 py-14 font-ttc15-sans text-white" aria-label="Pure Tailwind no-JavaScript 3D tilt card demo">
<div class="grid w-full justify-items-center gap-8">
<article class="group/card relative grid aspect-[31/24] w-full max-w-[20rem] grid-cols-3 grid-rows-3 rounded-[22px] border border-white/12 bg-linear-165 from-ttc15-card1 to-ttc15-card2 shadow-[0_28px_58px_-28px_oklch(0_0_0/0.8)] [--tx:10deg] [--ty:10deg] [transform:perspective(1000px)_rotateX(var(--rx,0deg))_rotateY(var(--ry,0deg))] [transition:transform_0.35s_ease,--ttc15-mx_0.35s_ease,--ttc15-my_0.35s_ease]
has-[[data-z=a]:hover]:[--rx:var(--tx)] has-[[data-z=a]:hover]:[--ry:calc(var(--ty)*-1)] has-[[data-z=a]:hover]:[--ttc15-mx:16%] has-[[data-z=a]:hover]:[--ttc15-my:16%]
has-[[data-z=b]:hover]:[--rx:var(--tx)] has-[[data-z=b]:hover]:[--ry:0deg] has-[[data-z=b]:hover]:[--ttc15-mx:50%] has-[[data-z=b]:hover]:[--ttc15-my:16%]
has-[[data-z=c]:hover]:[--rx:var(--tx)] has-[[data-z=c]:hover]:[--ry:var(--ty)] has-[[data-z=c]:hover]:[--ttc15-mx:84%] has-[[data-z=c]:hover]:[--ttc15-my:16%]
has-[[data-z=d]:hover]:[--rx:0deg] has-[[data-z=d]:hover]:[--ry:calc(var(--ty)*-1)] has-[[data-z=d]:hover]:[--ttc15-mx:16%] has-[[data-z=d]:hover]:[--ttc15-my:50%]
has-[[data-z=e]:hover]:[--rx:0deg] has-[[data-z=e]:hover]:[--ry:0deg] has-[[data-z=e]:hover]:[--ttc15-mx:50%] has-[[data-z=e]:hover]:[--ttc15-my:50%]
has-[[data-z=f]:hover]:[--rx:0deg] has-[[data-z=f]:hover]:[--ry:var(--ty)] has-[[data-z=f]:hover]:[--ttc15-mx:84%] has-[[data-z=f]:hover]:[--ttc15-my:50%]
has-[[data-z=g]:hover]:[--rx:calc(var(--tx)*-1)] has-[[data-z=g]:hover]:[--ry:calc(var(--ty)*-1)] has-[[data-z=g]:hover]:[--ttc15-mx:16%] has-[[data-z=g]:hover]:[--ttc15-my:84%]
has-[[data-z=h]:hover]:[--rx:calc(var(--tx)*-1)] has-[[data-z=h]:hover]:[--ry:0deg] has-[[data-z=h]:hover]:[--ttc15-mx:50%] has-[[data-z=h]:hover]:[--ttc15-my:84%]
has-[[data-z=i]:hover]:[--rx:calc(var(--tx)*-1)] has-[[data-z=i]:hover]:[--ry:var(--ty)] has-[[data-z=i]:hover]:[--ttc15-mx:84%] has-[[data-z=i]:hover]:[--ttc15-my:84%]
has-[:focus-visible]:[--rx:3deg] [@media(hover:none)]:[transform:none] motion-reduce:[transform:none] motion-reduce:[transition:none]">
<i data-z="a" class="z-30"></i><i data-z="b" class="z-30"></i><i data-z="c" class="z-30"></i>
<i data-z="d" class="z-30"></i><i data-z="e" class="z-30"></i><i data-z="f" class="z-30"></i>
<i data-z="g" class="z-30"></i><i data-z="h" class="z-30"></i><i data-z="i" class="z-30"></i>
<div aria-hidden="true" class="pointer-events-none absolute inset-0 z-10 rounded-[inherit] bg-[radial-gradient(240px_circle_at_var(--ttc15-mx)_var(--ttc15-my),oklch(1_0_0/0.17),transparent_62%)] opacity-0 transition-opacity duration-350 group-hover/card:opacity-100 motion-reduce:hidden"></div>
<div class="pointer-events-none absolute inset-0 z-20 flex flex-col p-7">
<p class="font-ttc15-mono text-[10.5px] font-bold tracking-[0.2em] text-ttc15-acc">0 KB OF JAVASCRIPT</p>
<h3 class="mt-3 text-[30px] leading-none font-bold tracking-[-0.02em]">Pure CSS Tilt</h3>
<p class="mt-3 text-[14px] leading-[1.55] text-ttc15-mut text-pretty">Nine invisible hover zones + <code class="font-ttc15-mono text-[12.5px] text-white">:has()</code> — no listeners, no library, no main-thread work.</p>
</div>
<a href="#" class="pointer-events-auto absolute bottom-7 left-7 z-40 inline-flex min-h-11 items-center rounded-full bg-ttc15-acc px-5 text-[13px] font-semibold text-ttc15-bg no-underline transition-[filter] hover:brightness-110 focus-visible:outline-3 focus-visible:outline-offset-3 focus-visible:outline-white">Read the technique</a>
</article>
<p aria-hidden="true" class="rounded-full border border-white/12 bg-white/5 px-3.5 py-2 font-ttc15-mono text-[11.5px] text-ttc15-mut">has-[[data-z=c]:hover]:[--rx:var(--tx)] · @property glare</p>
</div>
</section>
```
## CSS
```css
@import "tailwindcss";
@theme {
--color-ttc15-bg: #0e0d15;
--color-ttc15-bg: oklch(0.15 0.015 280);
--color-ttc15-lobe: #3a3014cc;
--color-ttc15-lobe: oklch(0.26 0.05 85 / 0.8);
--color-ttc15-card1: #2a2619;
--color-ttc15-card1: oklch(0.22 0.03 85);
--color-ttc15-card2: #16141d;
--color-ttc15-card2: oklch(0.16 0.02 280);
--color-ttc15-acc: #f0c14b;
--color-ttc15-acc: oklch(0.83 0.14 85);
--color-ttc15-mut: #c9c5cf;
--color-ttc15-mut: oklch(0.83 0.015 290);
--font-ttc15-sans: 'Space Grotesk', system-ui, sans-serif;
--font-ttc15-mono: 'JetBrains Mono', ui-monospace, monospace;
}
/* Registered so the glare centre INTERPOLATES between zones instead of jumping — an at-rule, so it stays CSS. */
@property --ttc15-mx {
syntax: "<percentage>";
inherits: true;
initial-value: 50%;
}
@property --ttc15-my {
syntax: "<percentage>";
inherits: true;
initial-value: 50%;
}
/* 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-15 {
width: 100%;
min-height: 100svh;
}
```How this works
The pose map is utilities. The CSS original had nine .card:has(.z:nth-child(n):hover) rules. In Tailwind each becomes four arbitrary-variant utilities on the card itself — e.g. the top-right zone:
has-[[data-z=c]:hover]:[--rx:var(--tx)] has-[[data-z=c]:hover]:[--ry:var(--ty)]
has-[[data-z=c]:hover]:[--ttc15-mx:84%] has-[[data-z=c]:hover]:[--ttc15-my:16%]Zones use letters (a–i) because an unquoted attribute value can't start with a digit.
Why the glare now glides. Unregistered custom properties swap instantly, so the original glare jumped zone to zone. Registering --ttc15-mx/--ttc15-my as <percentage> makes them interpolable, and listing them in the card's transition eases the highlight exactly like the tilt.
Layering. Zones (z-30) sit above the pointer-events-none content (z-20); the CTA (z-40) sits above the zones so it stays clickable.
Make it yours
- Finer tracking: a 4×4 grid (16 zones, 64 utilities) — generate the class list with a template loop.
- Retarget the pose angles with
[--tx:10deg] [--ty:10deg]on the card. - Keyboard lift:
has-[:focus-visible]:[--rx:3deg]is already wired — raise it for more feedback. - Reuse the zone grid for any per-region effect: spotlight colour, content reveal — all in CSS.
Gotchas — read before shipping
- The CTA must out-rank the zones (
z-40overz-30) or clicks die on an invisible sensor. - Don't drop the transition under ~250ms — the easing is what hides the 9-step quantisation.
:has()arrived in Firefox 121; behind it the card simply doesn't tilt — it's progressive enhancement.
Browser support
| Chrome | Safari | Firefox | Edge |
|---|---|---|---|
| 111+ | 16.4+ | 128+ | 111+ |
:has() is load-bearing; @property for the gliding glare is Firefox 128+. Everything degrades to a styled static card.