Pure CSS Cards & galleries #hover #cards #grid #ui

Flip tile grid in pure CSS

Six tiles that lift and flip to show their back. Odd tiles turn sideways, even ones head over heels, from a single custom property holding the rotation.

  • 71 lines of CSS
  • 22 lines of HTML
  • No JavaScript
  • No dependencies
  • MIT licensed

How it works

  1. The tile never moves — it is the hit target. Only the .tile-inner wrapper inside it flips, on :hover / :focus-visible.
  2. One custom property, --flip, holds the whole rotation function (rotateY(180deg) or rotateX(180deg)), set once per tile so a single rule can flip every tile differently.
  3. The back face is pre-rotated by that same --flip in its resting state, so once the tile turns, the back lands right-way up instead of mirrored.
  4. backface-visibility: hidden on both faces means only the one currently facing you is ever visible.
  5. Lifting with translateZ(30px) before the flip keeps the tile from clipping into its neighbours mid-turn.

Key techniques

  • a transform function stored in --flip
  • static tile, flipping inner
  • container pointer-events: none
  • backface-visibility: hidden

Copy-paste code

HTML 22 lines

<div class="scene">
  <div class="grid">
    <div class="tile" tabindex="0" style="--c:#8b6cff">
      <div class="tile-inner"><b>01</b><span>tilt</span></div>
    </div>
    <div class="tile" tabindex="0" style="--c:#ff4d9d">
      <div class="tile-inner"><b>02</b><span>flip</span></div>
    </div>
    <div class="tile" tabindex="0" style="--c:#2ee6d6">
      <div class="tile-inner"><b>03</b><span>spin</span></div>
    </div>
    <div class="tile" tabindex="0" style="--c:#ffb547">
      <div class="tile-inner"><b>04</b><span>lift</span></div>
    </div>
    <div class="tile" tabindex="0" style="--c:#8b6cff">
      <div class="tile-inner"><b>05</b><span>fold</span></div>
    </div>
    <div class="tile" tabindex="0" style="--c:#ff4d9d">
      <div class="tile-inner"><b>06</b><span>zoom</span></div>
    </div>
  </div>
</div>

CSS 71 lines

.scene {
  perspective: 800px;
}

.grid {
  display: grid;
  grid-template-columns: repeat(3, 62px);
  gap: 9px;
  transform-style: preserve-3d;
  transform: rotateX(16deg);
  /* the grid and its tiles are coplanar: take the grid out of hit-testing so the
     pointer always resolves to a tile, never something ambiguous between them */
  pointer-events: none;
}

.tile {
  --flip: rotateY(180deg); /* odd tiles flip sideways */
  position: relative;
  height: 62px;
  outline: none;
  cursor: pointer;
  pointer-events: auto;
  transform-style: preserve-3d;
}

.tile:nth-child(even) {
  --flip: rotateX(180deg); /* even tiles flip head over heels */
}

.tile-inner {
  position: absolute;
  inset: 0;
  pointer-events: none;
  transform-style: preserve-3d;
  transition: transform 0.6s cubic-bezier(0.3, 1.25, 0.5, 1);
}

.tile-inner > * {
  position: absolute;
  inset: 0;
  display: grid;
  place-items: center;
  border-radius: 10px;
  backface-visibility: hidden;
}

/* front */
.tile-inner b {
  color: #eceefb;
  font-size: 20px;
  font-weight: 900;
  background: color-mix(in srgb, var(--c) 26%, transparent);
  border: 1px solid color-mix(in srgb, var(--c) 75%, transparent);
  box-shadow: inset 0 0 24px color-mix(in srgb, var(--c) 30%, transparent);
}

/* back: pre-turned with the same rotation the tile will make, so it lands the right way up */
.tile-inner span {
  background: linear-gradient(135deg, var(--c), color-mix(in srgb, var(--c) 45%, #000));
  color: #fff;
  font-size: 12px;
  font-weight: 800;
  letter-spacing: 0.04em;
  transform: var(--flip);
}

.tile:hover .tile-inner,
.tile:focus-visible .tile-inner {
  /* lift first (toward the viewer), then flip in place */
  transform: translateZ(30px) var(--flip);
}

Sass source 69 lines

@use '../mixins' as *;

.d-flipgrid {
  display: grid;
  grid-template-columns: repeat(3, 62px);
  gap: 9px;
  transform-style: preserve-3d;
  transform: rotateX(16deg);
  // The grid and its tiles share one 3D plane, and coplanar surfaces have no stable hit-test
  // order. Take the grid out of hit-testing so the pointer always lands on a tile.
  pointer-events: none;

  // The tile is the hit target and never moves; only __inner flips.
  &__tile {
    // odd tiles flip sideways, even tiles flip head over heels
    --flip: rotateY(180deg);
    position: relative;
    height: 62px;
    outline: none;
    cursor: pointer;
    pointer-events: auto;
    transform-style: preserve-3d;

    &:nth-child(even) {
      --flip: rotateX(180deg);
    }
  }

  &__inner {
    position: absolute;
    inset: 0;
    pointer-events: none;
    transform-style: preserve-3d;
    transition: transform 0.6s cubic-bezier(0.3, 1.25, 0.5, 1);

    > * {
      position: absolute;
      inset: 0;
      display: grid;
      place-items: center;
      border-radius: 10px;
      backface-visibility: hidden;
    }

    // front
    b {
      color: var(--text);
      font-size: 20px;
      font-weight: 900;
      @include face(var(--c), 26%);
    }

    // back: pre-turned with the same rotation the tile will make, so it lands the right way up
    span {
      background: linear-gradient(135deg, var(--c), color-mix(in srgb, var(--c) 45%, #000));
      color: #fff;
      font-size: 12px;
      font-weight: 800;
      letter-spacing: 0.04em;
      transform: var(--flip);
    }
  }

  &__tile:hover &__inner,
  &__tile:focus-visible &__inner {
    // lift first (toward the viewer), then flip in place
    transform: translateZ(30px) var(--flip);
  }
}
Standalone snippet · plain CSS, no build step, no dependencies.