Pure CSS Buttons & forms #hover #form-hack #sass-loop

No-JS tilt in pure CSS

Pointer-following tilt without JavaScript: nine invisible hover zones steer the card.

  • 37 lines of CSS
  • 6 lines of HTML
  • No JavaScript
  • No dependencies
  • MIT licensed

How it works

  1. CSS cannot read pointer coordinates — but it can tell which element is hovered.
  2. Lay a 3 × 3 grid of invisible zones over the area. Each zone knows its row and column.
  3. The card comes after the zones in the markup, so zone:hover ~ .card can set a tilt toward that zone.
  4. pointer-events: none on the card lets the pointer fall through to the zones. More zones = smoother tilt; the JS version is smoother still.

Key techniques

  • 3 × 3 grid of hover zones
  • :hover ~ sibling selector
  • pointer-events: none on the card

Copy-paste code

HTML 6 lines

<div class="zones">
  <i></i><i></i><i></i>
  <i></i><i></i><i></i>
  <i></i><i></i><i></i>
  <div class="card">Hover around me</div>
</div>

CSS 37 lines

.zones {
  position: relative;
  display: grid;
  grid-template: repeat(3, 1fr) / repeat(3, 1fr);
  width: 420px;
  height: 300px;
  perspective: 800px;
}

.zones i { z-index: 1; }   /* invisible hover targets, above the card */

.card {
  position: absolute;
  inset: 0;
  display: grid;
  place-items: center;
  width: 260px;
  height: 160px;
  margin: auto;
  border-radius: 18px;
  color: #fff;
  font: 800 1.1rem system-ui;
  background: linear-gradient(135deg, #2ee6d6, #8b6cff);
  box-shadow: 0 22px 36px -16px #8b6cff;
  pointer-events: none;
  transition: transform 0.35s ease-out;
}

/* row 1 tilts back, row 3 tilts forward; column 1 turns left, column 3 turns right */
.zones i:nth-child(1):hover ~ .card { transform: rotateX(22deg)  rotateY(-22deg); }
.zones i:nth-child(2):hover ~ .card { transform: rotateX(22deg); }
.zones i:nth-child(3):hover ~ .card { transform: rotateX(22deg)  rotateY(22deg); }
.zones i:nth-child(4):hover ~ .card { transform: rotateY(-22deg); }
.zones i:nth-child(6):hover ~ .card { transform: rotateY(22deg); }
.zones i:nth-child(7):hover ~ .card { transform: rotateX(-22deg) rotateY(-22deg); }
.zones i:nth-child(8):hover ~ .card { transform: rotateX(-22deg); }
.zones i:nth-child(9):hover ~ .card { transform: rotateX(-22deg) rotateY(22deg); }

Sass source 52 lines

$tilt: 22deg;

.d-zones {
  position: absolute;
  inset: 0;
  display: grid;
  grid-template: repeat(3, 1fr) / repeat(3, 1fr);
  transform-style: preserve-3d;
  // The container lies on exactly the same 3D plane as its children. Coplanar surfaces have no
  // stable front-to-back order, so in patches the browser hit-tests the (invisible) container
  // instead of the child: wrong cursor, ignored hover. Only the children should catch the pointer.
  pointer-events: none;

  // nine invisible hover targets covering the stage
  i {
    z-index: 1;
    pointer-events: auto;
  }

  &__card {
    position: absolute;
    inset: 0;
    display: grid;
    place-content: center;
    gap: 2px;
    width: 200px;
    height: 124px;
    margin: auto;
    border-radius: 16px;
    background: linear-gradient(135deg, var(--accent-2), var(--accent));
    color: #fff;
    font-weight: 800;
    text-align: center;
    box-shadow: 0 22px 36px -16px var(--accent);
    pointer-events: none; // let the pointer reach the zones underneath
    transition: transform 0.35s ease-out;

    small {
      font-weight: 500;
      opacity: 0.85;
    }
  }

  // zone (row, col) tilts the card toward that corner
  @for $row from 0 through 2 {
    @for $col from 0 through 2 {
      i:nth-child(#{$row * 3 + $col + 1}):hover ~ &__card {
        transform: rotateX((1 - $row) * $tilt) rotateY(($col - 1) * $tilt);
      }
    }
  }
}
Standalone snippet · plain CSS, no build step, no dependencies.