Pure TailwindMIT licensed

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.

Live Demo
Plain CSSTailwind v4
Try it

Untitled UI Pro: 5,000+ React + Tailwind CSS v4 components · from $349 one-time

Untitled UI React · Affiliate link

Get Untitled UI Pro

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>
@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.

1Load Tailwind
<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.

2Add the stylesheetRequiredthe @theme block is what makes the custom utilities exist
@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;
}
3Fonts used by this demooptional — skip if you have your own type stack
<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 from nth-child to data-z letters so the markup reads as a map.
  • The @media (hover:none) and reduced-motion overrides became [@media(hover:none)]:[transform:none] and motion-reduce: utilities.
  • UI upgrade from the CSS original: the glare now glides between zones — @property-registered --ttc15-mx/--ttc15-my are 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 dead tabindex was removed, and a keyboard lift (has-[:focus-visible]:) replaced the :focus-within rule.
Paste this into ChatGPT, Claude, Cursor, or any coding assistant. The block below is pre-framed with everything the AI needs to integrate this demo into your project — markup, styles, scoping notes, and the source URL. Hit Copy and paste straight into your chat.
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-40 over z-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

ChromeSafariFirefoxEdge
111+16.4+128+111+

:has() is load-bearing; @property for the gliding glare is Firefox 128+. Everything degrades to a styled static card.

Techniques used in this demo

Utilitiesfirst-class utility or variant

Theme tokensset in @theme, used by a utility

Plain CSSno Tailwind surface

Search CodeFronts

Loading…