Pure CSS Buttons & forms #hover

No-JS tilt in pure CSS

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

  • 47 lines of CSS
  • 6 lines of HTML
  • No JavaScript
  • No dependencies
  • Snippet under MIT
GitHub

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 canvas. 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.
  5. The zones fill the whole canvas, so the pointer steers the card from anywhere on it. The card itself is sized in one base unit, --u, tied to the canvas, so it is the same share of a gallery card, the editor and a recording canvas.

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" tabindex="0" role="img" aria-label="Card that tilts toward the zone the pointer is in">
  <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 47 lines

.zones {
  /* one base unit: every length below is a multiple of it, so the card is the same share of a
     card, the editor, a full screen and a recording canvas */
  --u: 0.32vmin;
  position: relative;
  display: grid;
  grid-template: repeat(3, 1fr) / repeat(3, 1fr);
  /* the zones are the whole canvas, so the tilt follows the pointer anywhere on it and no hit
     area ever hangs past the canvas edge */
  width: 100%;
  height: 100%;
  perspective: calc(800 * var(--u));
}

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

.card {
  position: absolute;
  inset: 0;
  display: grid;
  place-items: center;
  width: calc(260 * var(--u));
  height: calc(160 * var(--u));
  margin: auto;
  border-radius: calc(18 * var(--u));
  color: #fff;
  font: 800 calc(17.6 * var(--u)) Inter, system-ui;
  /* deep enough for white words, 5.2:1 at its worst end: the bright #2ee6d6 was 2.2:1 */
  background: linear-gradient(135deg, #087a70, #6a45f5);
  box-shadow: 0 calc(22 * var(--u)) calc(36 * var(--u)) calc(-16 * var(--u)) #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); }

/* the keyboard: one stop for the whole, and the card tilts as it does for the top right zone, even
   with the pointer resting in another zone */
.zones:focus-visible .card { transform: rotateX(22deg) rotateY(22deg) !important; }
Standalone snippet · plain CSS, no build step, no dependencies.