15 CSS 3D Tilt Hover Cards15 / 15

Pure CSSMIT licensed

Pure CSS Touch-Friendly Tilt Card

Zero JavaScript, zero listeners: a 3×3 grid of invisible hover zones overlays the card, and :has() maps whichever zone the cursor occupies to a predefined tilt pose — nine discrete poses that eased transitions blend into motion that feels continuous. The accessible, dependency-free fallback every tilt collection needs.

Published

Live Demo
Try it

The code

<section class="thc-15" aria-label="Pure CSS no-JavaScript tilt card demo">
  <article class="thc-15__card" tabindex="0" aria-label="CSS-only tilt card, no JavaScript required">
    <i class="thc-15__z"></i><i class="thc-15__z"></i><i class="thc-15__z"></i>
    <i class="thc-15__z"></i><i class="thc-15__z"></i><i class="thc-15__z"></i>
    <i class="thc-15__z"></i><i class="thc-15__z"></i><i class="thc-15__z"></i>
    <div class="thc-15__glare" aria-hidden="true"></div>
    <div class="thc-15__content">
      <p class="thc-15__kicker">0 KB OF JAVASCRIPT</p>
      <h3 class="thc-15__title">Pure CSS Tilt</h3>
      <p class="thc-15__body">Nine invisible hover zones + :has() = a tilt card with no listeners, no library, no main-thread work.</p>
    </div>
    <a class="thc-15__cta" href="#">Read the technique</a>
  </article>
</section>
.thc-15,
.thc-15 *,
.thc-15 *::before,
.thc-15 *::after {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}

.thc-15 {
  --acc: oklch(0.78 0.15 85);
  --tx: 10deg;
  --ty: 10deg;
  font-family: 'Segoe UI',system-ui,sans-serif;
  width: 100%;
  min-height: 100vh;
  display: grid;
  place-items: center;
  padding: 48px 24px;
  background: radial-gradient(80% 90% at 25% 15%,oklch(0.26 0.05 85 / .8),transparent 60%),oklch(0.15 0.015 280);
}

.thc-15 .thc-15__card {
  position: relative;
  width: min(310px,100%);
  aspect-ratio: 31/24;
  border-radius: 20px;
  background: linear-gradient(165deg,oklch(0.22 0.03 85),oklch(0.16 0.02 280));
  border: 1px solid rgba(255,255,255,.12);
  display: grid;
  grid-template: repeat(3,1fr)/repeat(3,1fr);
  transform: perspective(1000px) rotateX(var(--rx,0deg)) rotateY(var(--ry,0deg));
  transform-style: preserve-3d;
  transition: transform .35s ease;
  box-shadow: 0 28px 58px -28px rgba(0,0,0,.8);
}

.thc-15 .thc-15__card:focus-visible {
  outline: 3px solid var(--acc);
  outline-offset: 4px;
}
/* Zones sit ABOVE the content overlay so hover always reaches a zone.
   Content is pointer-events:none by default so the zone <i>'s underneath
   stay hoverable through the visual content; the CTA re-enables pointer
   events on itself so it remains clickable. Without this, moving the
   cursor over the title / kicker / body would block zone :hover and the
   card would freeze mid-tilt. */

.thc-15 .thc-15__z {
  z-index: 3;
}

.thc-15 .thc-15__glare {
  position: absolute;
  inset: 0;
  border-radius: inherit;
  pointer-events: none;
  background: radial-gradient(240px circle at var(--mx,50%) var(--my,50%),rgba(255,255,255,.17),transparent 62%);
  opacity: 0;
  transition: opacity .35s ease,background-position .35s ease;
  z-index: 1;
}

.thc-15 .thc-15__card:hover .thc-15__glare {
  opacity: 1;
}

.thc-15 .thc-15__content {
  position: absolute;
  inset: 0;
  padding: 30px 28px;
  display: flex;
  flex-direction: column;
  transform: translateZ(30px);
  pointer-events: none;
  z-index: 2;
}

.thc-15 .thc-15__kicker {
  font: 700 10px ui-monospace,monospace;
  letter-spacing: .2em;
  color: var(--acc);
}

.thc-15 .thc-15__title {
  margin-top: 12px;
  font-size: 28px;
  font-weight: 800;
  color: #fff;
}

.thc-15 .thc-15__body {
  margin-top: 10px;
  font-size: 13.5px;
  line-height: 1.55;
  color: rgba(255,255,255,.65);
  text-wrap: pretty;
}

.thc-15 .thc-15__cta {
  position: absolute;
  left: 28px;
  bottom: 28px;
  z-index: 4;
  font: 600 13px 'Segoe UI',sans-serif;
  color: oklch(0.15 0.015 280);
  background: var(--acc);
  text-decoration: none;
  border-radius: 999px;
  padding: 10px 18px;
  transform: translateZ(45px);
}

.thc-15 .thc-15__cta:focus-visible {
  outline: 3px solid #fff;
  outline-offset: 3px;
}
/* 3x3 pose map — row 1 */

.thc-15 .thc-15__card:has(.thc-15__z:nth-child(1):hover) {
  --rx: var(--tx);
  --ry: calc(var(--ty) * -1);
  --mx: 16%;
  --my: 16%;
}

.thc-15 .thc-15__card:has(.thc-15__z:nth-child(2):hover) {
  --rx: var(--tx);
  --ry: 0deg;
  --mx: 50%;
  --my: 16%;
}

.thc-15 .thc-15__card:has(.thc-15__z:nth-child(3):hover) {
  --rx: var(--tx);
  --ry: var(--ty);
  --mx: 84%;
  --my: 16%;
}
/* row 2 */

.thc-15 .thc-15__card:has(.thc-15__z:nth-child(4):hover) {
  --rx: 0deg;
  --ry: calc(var(--ty) * -1);
  --mx: 16%;
  --my: 50%;
}

.thc-15 .thc-15__card:has(.thc-15__z:nth-child(5):hover) {
  --rx: 0deg;
  --ry: 0deg;
  --mx: 50%;
  --my: 50%;
}

.thc-15 .thc-15__card:has(.thc-15__z:nth-child(6):hover) {
  --rx: 0deg;
  --ry: var(--ty);
  --mx: 84%;
  --my: 50%;
}
/* row 3 */

.thc-15 .thc-15__card:has(.thc-15__z:nth-child(7):hover) {
  --rx: calc(var(--tx) * -1);
  --ry: calc(var(--ty) * -1);
  --mx: 16%;
  --my: 84%;
}

.thc-15 .thc-15__card:has(.thc-15__z:nth-child(8):hover) {
  --rx: calc(var(--tx) * -1);
  --ry: 0deg;
  --mx: 50%;
  --my: 84%;
}

.thc-15 .thc-15__card:has(.thc-15__z:nth-child(9):hover) {
  --rx: calc(var(--tx) * -1);
  --ry: var(--ty);
  --mx: 84%;
  --my: 84%;
}

.thc-15 .thc-15__card:focus-within {
  --rx: 2deg;
  --ry: 0deg;
}

@media (hover: none) {
  .thc-15 .thc-15__card {
    transform: none;
  }
}

@media (prefers-reduced-motion: reduce) {
  .thc-15 .thc-15__card,
    .thc-15__glare {
    transition: none;
  }

  .thc-15 .thc-15__card:has(.thc-15__z:hover) {
    --rx: 0deg;
    --ry: 0deg;
  }
}
/* No JavaScript — nine invisible hover zones + :has() map the cursor to predefined tilt poses; eased transitions blend the steps into motion. */
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 3D Tilt Hover Card 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 CSS Touch-Friendly Tilt Card
Source: https://codefronts.com/components/css-3d-tilt-hover-cards/pure-css-touch-friendly-tilt-card/

Zero JavaScript, zero listeners: a 3×3 grid of invisible hover zones overlays the card, and :has() maps whichever zone the cursor occupies to a predefined tilt pose — nine discrete poses that eased transitions blend into motion that feels continuous. The accessible, dependency-free fallback every tilt collection needs.
## HTML
```html
<section class="thc-15" aria-label="Pure CSS no-JavaScript tilt card demo">
  <article class="thc-15__card" tabindex="0" aria-label="CSS-only tilt card, no JavaScript required">
    <i class="thc-15__z"></i><i class="thc-15__z"></i><i class="thc-15__z"></i>
    <i class="thc-15__z"></i><i class="thc-15__z"></i><i class="thc-15__z"></i>
    <i class="thc-15__z"></i><i class="thc-15__z"></i><i class="thc-15__z"></i>
    <div class="thc-15__glare" aria-hidden="true"></div>
    <div class="thc-15__content">
      <p class="thc-15__kicker">0 KB OF JAVASCRIPT</p>
      <h3 class="thc-15__title">Pure CSS Tilt</h3>
      <p class="thc-15__body">Nine invisible hover zones + :has() = a tilt card with no listeners, no library, no main-thread work.</p>
    </div>
    <a class="thc-15__cta" href="#">Read the technique</a>
  </article>
</section>
```
## CSS
```css
.thc-15,
.thc-15 *,
.thc-15 *::before,
.thc-15 *::after {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}

.thc-15 {
  --acc: oklch(0.78 0.15 85);
  --tx: 10deg;
  --ty: 10deg;
  font-family: 'Segoe UI',system-ui,sans-serif;
  width: 100%;
  min-height: 100vh;
  display: grid;
  place-items: center;
  padding: 48px 24px;
  background: radial-gradient(80% 90% at 25% 15%,oklch(0.26 0.05 85 / .8),transparent 60%),oklch(0.15 0.015 280);
}

.thc-15 .thc-15__card {
  position: relative;
  width: min(310px,100%);
  aspect-ratio: 31/24;
  border-radius: 20px;
  background: linear-gradient(165deg,oklch(0.22 0.03 85),oklch(0.16 0.02 280));
  border: 1px solid rgba(255,255,255,.12);
  display: grid;
  grid-template: repeat(3,1fr)/repeat(3,1fr);
  transform: perspective(1000px) rotateX(var(--rx,0deg)) rotateY(var(--ry,0deg));
  transform-style: preserve-3d;
  transition: transform .35s ease;
  box-shadow: 0 28px 58px -28px rgba(0,0,0,.8);
}

.thc-15 .thc-15__card:focus-visible {
  outline: 3px solid var(--acc);
  outline-offset: 4px;
}
/* Zones sit ABOVE the content overlay so hover always reaches a zone.
   Content is pointer-events:none by default so the zone <i>'s underneath
   stay hoverable through the visual content; the CTA re-enables pointer
   events on itself so it remains clickable. Without this, moving the
   cursor over the title / kicker / body would block zone :hover and the
   card would freeze mid-tilt. */

.thc-15 .thc-15__z {
  z-index: 3;
}

.thc-15 .thc-15__glare {
  position: absolute;
  inset: 0;
  border-radius: inherit;
  pointer-events: none;
  background: radial-gradient(240px circle at var(--mx,50%) var(--my,50%),rgba(255,255,255,.17),transparent 62%);
  opacity: 0;
  transition: opacity .35s ease,background-position .35s ease;
  z-index: 1;
}

.thc-15 .thc-15__card:hover .thc-15__glare {
  opacity: 1;
}

.thc-15 .thc-15__content {
  position: absolute;
  inset: 0;
  padding: 30px 28px;
  display: flex;
  flex-direction: column;
  transform: translateZ(30px);
  pointer-events: none;
  z-index: 2;
}

.thc-15 .thc-15__kicker {
  font: 700 10px ui-monospace,monospace;
  letter-spacing: .2em;
  color: var(--acc);
}

.thc-15 .thc-15__title {
  margin-top: 12px;
  font-size: 28px;
  font-weight: 800;
  color: #fff;
}

.thc-15 .thc-15__body {
  margin-top: 10px;
  font-size: 13.5px;
  line-height: 1.55;
  color: rgba(255,255,255,.65);
  text-wrap: pretty;
}

.thc-15 .thc-15__cta {
  position: absolute;
  left: 28px;
  bottom: 28px;
  z-index: 4;
  font: 600 13px 'Segoe UI',sans-serif;
  color: oklch(0.15 0.015 280);
  background: var(--acc);
  text-decoration: none;
  border-radius: 999px;
  padding: 10px 18px;
  transform: translateZ(45px);
}

.thc-15 .thc-15__cta:focus-visible {
  outline: 3px solid #fff;
  outline-offset: 3px;
}
/* 3x3 pose map — row 1 */

.thc-15 .thc-15__card:has(.thc-15__z:nth-child(1):hover) {
  --rx: var(--tx);
  --ry: calc(var(--ty) * -1);
  --mx: 16%;
  --my: 16%;
}

.thc-15 .thc-15__card:has(.thc-15__z:nth-child(2):hover) {
  --rx: var(--tx);
  --ry: 0deg;
  --mx: 50%;
  --my: 16%;
}

.thc-15 .thc-15__card:has(.thc-15__z:nth-child(3):hover) {
  --rx: var(--tx);
  --ry: var(--ty);
  --mx: 84%;
  --my: 16%;
}
/* row 2 */

.thc-15 .thc-15__card:has(.thc-15__z:nth-child(4):hover) {
  --rx: 0deg;
  --ry: calc(var(--ty) * -1);
  --mx: 16%;
  --my: 50%;
}

.thc-15 .thc-15__card:has(.thc-15__z:nth-child(5):hover) {
  --rx: 0deg;
  --ry: 0deg;
  --mx: 50%;
  --my: 50%;
}

.thc-15 .thc-15__card:has(.thc-15__z:nth-child(6):hover) {
  --rx: 0deg;
  --ry: var(--ty);
  --mx: 84%;
  --my: 50%;
}
/* row 3 */

.thc-15 .thc-15__card:has(.thc-15__z:nth-child(7):hover) {
  --rx: calc(var(--tx) * -1);
  --ry: calc(var(--ty) * -1);
  --mx: 16%;
  --my: 84%;
}

.thc-15 .thc-15__card:has(.thc-15__z:nth-child(8):hover) {
  --rx: calc(var(--tx) * -1);
  --ry: 0deg;
  --mx: 50%;
  --my: 84%;
}

.thc-15 .thc-15__card:has(.thc-15__z:nth-child(9):hover) {
  --rx: calc(var(--tx) * -1);
  --ry: var(--ty);
  --mx: 84%;
  --my: 84%;
}

.thc-15 .thc-15__card:focus-within {
  --rx: 2deg;
  --ry: 0deg;
}

@media (hover: none) {
  .thc-15 .thc-15__card {
    transform: none;
  }
}

@media (prefers-reduced-motion: reduce) {
  .thc-15 .thc-15__card,
    .thc-15__glare {
    transition: none;
  }

  .thc-15 .thc-15__card:has(.thc-15__z:hover) {
    --rx: 0deg;
    --ry: 0deg;
  }
}
```

## JavaScript
```js
/* No JavaScript — nine invisible hover zones + :has() map the cursor to predefined tilt poses; eased transitions blend the steps into motion. */
```

How this works

Nine empty <i> elements tile the card in a 3×3 grid, each a silent hover sensor. The card watches them with the modern parent selector: .card:has(.z:nth-child(3):hover) sets the pose for the top-right zone — rotateX up, rotateY right, glare in that corner — and so on for all nine. Because every pose change animates through a 350ms eased transition, the stepping between zones reads as smooth tracking, not snapping.

Each zone also repositions the glare via custom properties set in the same :has() rule — the same variable-bridge pattern as the JS demos, just driven by selectors. Nine zones is the sweet spot: 25 zones (5×5) is smoother but the selector block grows quadratically; four is visibly steppy. On touch devices there's no hover, so the card stays at rest — which IS the touch-friendly behaviour.

Make it yours

  • Upgrade to a 4×4 grid for finer tracking — generate the 16 :has() rules with a Sass/PostCSS loop.
  • Retarget the pose angles (±10° here) with the two custom properties --tilt-x/--tilt-y.
  • Add a :focus-within pose so keyboard users get a gentle lift when tabbing to the CTA.
  • Reuse the zone grid to drive any per-region effect — spotlight color, content reveal — all in CSS.

Gotchas — read before shipping

  • Zones must be pointer-transparent for CONTENT but not for hover: keep them above content but give the CTA a higher z-index, or clicks die.
  • Don't shrink the transition under ~250ms — the eased blur between poses is what hides the 9-step quantization.
  • :has() arrived in Firefox 121 (Dec 2023) — behind it, the card simply doesn't tilt; ship it as progressive enhancement.

Browser support

ChromeSafariFirefoxEdge
114+17.5+121+114+

Floor set by :has(), oklch(), text-wrap as this demo is written. The core technique itself goes back further — Chrome 111+.

:has() is the load-bearing feature. Everything degrades to a beautiful static card where unsupported — that's the point of the pattern.

Techniques used in this demo

Search CodeFronts

Loading…