Pure CSS Scenes & objects #hover

Hover tiles in pure CSS

Sweep across the floor. Tiles rise instantly and sink slowly, leaving a trail.

  • 54 lines of CSS
  • 9 lines of HTML
  • No JavaScript
  • No dependencies
  • Snippet under MIT
GitHub

How it works

  1. The browser hit-tests in 3D: :hover works on the tile you actually see under the pointer, even on a tilted plane.
  2. The hovered cell itself never moves — only its ::before plate lifts. If the hovered element moved, it would slide out from under the pointer and flicker.
  3. The trail effect is two transition speeds. The base rule has a slow 1.4s transition — that one applies when the hover ends.
  4. The :hover rule overrides transition-duration to 0.08s — that one applies when the hover starts.
  5. Result: tiles pop up instantly and sink back slowly.
  6. Every length is a multiple of one base unit, --u, tied to the canvas: a tile is 46 units square and its plate rises 46, so the floor is the same share of a gallery card, the editor and a recording canvas.

Key techniques

  • hit-testing works in 3D
  • fast transition in, slow transition out
  • isometric floor

Copy-paste code

HTML 9 lines

<div class="scene">
  <div class="floor" tabindex="0" role="img" aria-label="Floor of tiles; each rises as the pointer passes over it">
    <i></i><i></i><i></i><i></i><i></i>
    <i></i><i></i><i></i><i></i><i></i>
    <i></i><i></i><i></i><i></i><i></i>
    <i></i><i></i><i></i><i></i><i></i>
    <i></i><i></i><i></i><i></i><i></i>
  </div>
</div>

CSS 54 lines

.scene {
  /* one base unit: every length below is a multiple of it, so the floor is the same share of a
     gallery card, the editor, a full screen and a recording canvas */
  --u: 0.24vmin;
  perspective: calc(900 * var(--u));
}

.floor {
  display: grid;
  grid-template-columns: repeat(5, calc(46 * var(--u)));
  gap: calc(5 * var(--u));
  transform-style: preserve-3d;
  /* the camera magnifies the near half of the tilted floor, so at rest it draws further below its
     own middle than above; a raised plate at the far corner reaches the other way, up the screen.
     This small lift splits the two, so the floor at rest and the floor with its highest plate up
     are both centred to within a few units */
  transform: translateY(calc(-6 * var(--u))) rotateX(56deg) rotateZ(-45deg);
  /* same plane as its cells: keep the floor itself out of hit-testing, or hover misses in patches */
  pointer-events: none;
}

/* the cell is a fixed hit target; only its ::before plate moves */
.floor i {
  pointer-events: auto;
  position: relative;
  height: calc(46 * var(--u));
  transform-style: preserve-3d;
}

.floor i::before {
  content: '';
  position: absolute;
  inset: 0;
  border-radius: calc(7 * var(--u));
  background: rgb(139 108 255 / 0.35);
  border: calc(1 * var(--u)) solid rgb(139 108 255 / 0.7);
  pointer-events: none;
  /* slow on the way down */
  transition: transform 1.4s ease-out, background 1.4s ease-out;
}

.floor i:hover::before {
  background: #2ee6d6;
  transform: translateZ(calc(46 * var(--u)));
  /* instant on the way up */
  transition-duration: 0.08s;
}

/* the keyboard: one stop for the floor, and the middle plate rises as it does under the pointer */
.floor:focus-visible i:nth-child(13)::before {
  background: #2ee6d6;
  transform: translateZ(calc(46 * var(--u)));
  transition-duration: 0.08s;
}
Standalone snippet · plain CSS, no build step, no dependencies.