Pure CSS Cards & galleries #hover #cards #gallery #shape

Bookshelf in pure CSS

Five real books on a shelf, each a box with a spine, two covers and a block of pages. Point at one and it slides out toward you and turns to show its front cover, while its slot on the shelf stays put as the hit target.

  • 164 lines of CSS
  • 20 lines of HTML
  • No JavaScript
  • No dependencies
  • MIT licensed

How it works

  1. Each book is a box from four faces around its spine: the spine at the front, two covers turned ±90° on the spine's edges with transform-origin, and the page block laid flat on top. The bottom and the back are never seen, so they are not drawn.
  2. The slots (one per book, laid out by flexbox on the tilted shelf) are the hit targets. They are all in one plane, so the shelf gets pointer-events: none and only the slots auto; the books inside are pointer-events: none and free to move.
  3. The book turns around its own middle: transform-origin: 50% 50% -36px puts the pivot half its depth behind the spine.
  4. Pulling out uses the separate translate and rotate properties instead of one transform, so each gets its own transition and delay: slide out first, then turn; on the way back, turn first, then slide in. It never swings through its neighbours.

Key techniques

  • cuboid from 4 faces sized by --t / --h
  • transform-origin at the book’s 3D centre
  • static slots, pointer-events: none books
  • translateZ + rotateY to pull out

Copy-paste code

HTML 20 lines

<div class="scene">
  <div class="shelf">
    <i class="board"></i><i class="edge"></i><i class="end"></i>
    <div class="slot" tabindex="0" style="--t:20px; --h:112px; --c:#8b6cff">
      <div class="book"><i>Depth</i><i></i><i><b>Depth of field</b></i><i></i></div>
    </div>
    <div class="slot" tabindex="0" style="--t:16px; --h:98px; --c:#ffb547">
      <div class="book"><i>Z</i><i></i><i><b>The Z axis</b></i><i></i></div>
    </div>
    <div class="slot" tabindex="0" style="--t:24px; --h:122px; --c:#2ee6d6">
      <div class="book"><i>Perspective</i><i></i><i><b>Perspective</b></i><i></i></div>
    </div>
    <div class="slot" tabindex="0" style="--t:18px; --h:92px; --c:#ff4d9d">
      <div class="book"><i>Faces</i><i></i><i><b>Six faces</b></i><i></i></div>
    </div>
    <div class="slot" tabindex="0" style="--t:22px; --h:106px; --c:#5846b8">
      <div class="book"><i>Origin</i><i></i><i><b>Transform origin</b></i><i></i></div>
    </div>
  </div>
</div>

CSS 164 lines

.scene {
  perspective: 800px;
}

/* one plane seen at an angle; its slots are coplanar, so only they catch the pointer */
.shelf {
  position: relative;
  display: flex;
  align-items: flex-end;
  gap: 3px;
  padding: 0 12px;
  pointer-events: none;
  transform-style: preserve-3d;
  transform: rotateX(-16deg) rotateY(-28deg);
}

/* the back of the bookcase, behind the books (they are 72px deep) */
.shelf::before {
  content: '';
  position: absolute;
  inset: -18px -6px 0;
  border-radius: 6px 6px 0 0;
  background: linear-gradient(180deg, #1c1f3c, #262650);
  transform: translateZ(-80px);
}

/* the board: its top (laid flat), its front edge and its right end */
.board, .edge, .end {
  position: absolute;
  left: -6px;
}

.board {
  right: -6px;
  bottom: 0;
  height: 90px;
  background: linear-gradient(0deg, #b77a33, #6e4520);
  transform-origin: 50% 100%;
  transform: translateZ(10px) rotateX(90deg);
}

.edge {
  top: 100%;
  right: -6px;
  height: 10px;
  background: #d9953f;
  transform: translateZ(10px);
}

.end {
  top: 100%;
  left: calc(100% + 6px);
  width: 90px;
  height: 10px;
  background: #8a5a28;
  transform-origin: 0 50%;
  transform: translateZ(10px) rotateY(90deg);
}

/* a slot is exactly the book's spine at rest, and never moves */
.slot {
  position: relative;
  flex: none;
  width: var(--t);
  height: var(--h);
  outline: none;
  cursor: pointer;
  pointer-events: auto;
  transform-style: preserve-3d;
}

/* the book turns around its own middle, 36px behind the spine */
.book {
  position: absolute;
  inset: 0;
  pointer-events: none;
  transform-style: preserve-3d;
  transform-origin: 50% 50% -36px;
  translate: 0 0 0;
  rotate: y 0deg;
  /* going back: turn straight first, then slide in */
  transition:
    translate 0.4s cubic-bezier(0.3, 0.7, 0.4, 1) 0.35s,
    rotate 0.35s ease-in-out;
}

.slot:hover .book,
.slot:focus-visible .book {
  /* out further than the book is deep (72px), so it is clear of its neighbours before it turns */
  translate: 0 -5px 86px;
  rotate: y -36deg;
  /* coming out: slide all the way first, then turn */
  transition:
    translate 0.4s cubic-bezier(0.3, 0.7, 0.4, 1),
    rotate 0.5s cubic-bezier(0.3, 1.2, 0.5, 1) 0.4s;
}

.book i {
  position: absolute;
  top: 0;
  height: 100%;
}

/* the spine */
.book i:nth-child(1) {
  left: 0;
  width: 100%;
  display: grid;
  place-items: center;
  overflow: hidden;
  border-radius: 2px;
  background:
    linear-gradient(180deg, transparent 10px, rgb(255 255 255 / 0.55) 10px 12px, transparent 12px calc(100% - 12px), rgb(255 255 255 / 0.55) calc(100% - 12px) calc(100% - 10px), transparent 0),
    linear-gradient(90deg, color-mix(in srgb, var(--c) 60%, #000), var(--c) 30%, var(--c) 65%, color-mix(in srgb, var(--c) 70%, #000));
  color: #fff;
  font-size: 8px;
  font-weight: 700;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  white-space: nowrap;
  writing-mode: vertical-rl;
  backface-visibility: hidden;
}

/* back cover: hinged on the spine's left edge, turned to face left */
.book i:nth-child(2) {
  left: -72px;
  width: 72px;
  background: color-mix(in srgb, var(--c) 55%, #000);
  transform-origin: 100% 50%;
  transform: rotateY(-90deg);
}

/* front cover: hinged on the right edge, turned to face right */
.book i:nth-child(3) {
  left: 100%;
  display: grid;
  place-items: center;
  width: 72px;
  padding: 8px;
  border-radius: 0 3px 3px 0;
  background:
    radial-gradient(circle at 50% 36%, rgb(255 255 255 / 0.35) 0 12px, transparent 13px),
    linear-gradient(160deg, color-mix(in srgb, var(--c) 85%, #fff), color-mix(in srgb, var(--c) 70%, #000));
  color: #fff;
  font-size: 9px;
  text-align: center;
  transform-origin: 0 50%;
  transform: rotateY(90deg);
  backface-visibility: hidden;
}

/* the top of the page block, laid flat, running back from the spine */
.book i:nth-child(4) {
  top: -72px;
  left: 0;
  width: 100%;
  height: 72px;
  border: solid color-mix(in srgb, var(--c) 70%, #000);
  border-width: 0 2px 2px;
  background: repeating-linear-gradient(90deg, #f3ecdc 0 2px, #d9cfb8 2px 3px);
  transform-origin: 50% 100%;
  transform: rotateX(90deg);
}

Sass source 178 lines

$depth: 72px; // every book is this deep (the width of its covers)
$shelf: $depth + 18px; // the board runs a little in front of and behind the books
$ease: cubic-bezier(0.3, 1.2, 0.5, 1);
// how far a book comes out: its depth, plus what its back corners swing back while it turns, plus
// a margin (so it is always clear of its neighbours before it turns)
$pull: $depth + 14px;

// The shelf is one plane seen at an angle. Its slots (one per book, laid out by flexbox) are the
// hit targets; they are coplanar, so only they may catch the pointer.
.d-bookshelf {
  position: relative;
  display: flex;
  align-items: flex-end;
  gap: 3px;
  padding: 0 12px;
  pointer-events: none;
  transform-style: preserve-3d;
  transform: rotateX(-16deg) rotateY(-28deg);

  // the back of the bookcase
  &::before {
    content: '';
    position: absolute;
    inset: -18px -6px 0;
    border-radius: 6px 6px 0 0;
    background: linear-gradient(180deg, color-mix(in srgb, var(--accent) 10%, var(--surface-solid)), color-mix(in srgb, var(--accent) 22%, var(--surface-solid)));
    transform: translateZ(-$depth - 8px);
  }

  // the board: its top (laid flat, running back from the front edge), front edge and right end
  &__board,
  &__edge,
  &__end {
    position: absolute;
    left: -6px;
    background: color-mix(in srgb, var(--warm) 45%, #3a2412);
  }

  &__board {
    right: -6px;
    bottom: 0;
    height: $shelf;
    background: linear-gradient(0deg, color-mix(in srgb, var(--warm) 60%, #4a2e16), color-mix(in srgb, var(--warm) 30%, #2a180a));
    transform-origin: 50% 100%;
    transform: translateZ(10px) rotateX(90deg);
  }

  &__edge {
    top: 100%;
    right: -6px;
    height: 10px;
    background: color-mix(in srgb, var(--warm) 75%, #4a2e16);
    transform: translateZ(10px);
  }

  &__end {
    top: 100%;
    left: calc(100% + 6px);
    width: $shelf;
    height: 10px;
    background: color-mix(in srgb, var(--warm) 40%, #2a180a);
    transform-origin: 0 50%;
    transform: translateZ(10px) rotateY(90deg);
  }

  // A slot is exactly the book's spine at rest, and never moves.
  &__slot {
    position: relative;
    flex: none;
    width: var(--t);
    height: var(--h);
    outline: none;
    cursor: pointer;
    pointer-events: auto;
    transform-style: preserve-3d;
  }

  // The book: a box --t wide, --h tall and $depth deep, its spine at the front (z = 0). It turns
  // around its own middle. translate and rotate are separate properties, so each gets its own
  // transition: out first, then turn; on the way back, turn first, then slide in. Strictly one
  // after the other (each starts when the other has finished), with no overshoot on the slide,
  // and it comes out further than it is deep ($pull), so while it turns it is fully clear of the
  // shelf and can never swing through its neighbours.
  &__book {
    position: absolute;
    inset: 0;
    pointer-events: none;
    transform-style: preserve-3d;
    transform-origin: 50% 50% (-$depth * 0.5);
    translate: 0 0 0;
    rotate: y 0deg;
    transition:
      translate 0.4s cubic-bezier(0.3, 0.7, 0.4, 1) 0.35s,
      rotate 0.35s ease-in-out;

    i {
      position: absolute;
      top: 0;
      height: 100%;
    }

    // the spine
    i:nth-child(1) {
      left: 0;
      width: 100%;
      display: grid;
      place-items: center;
      overflow: hidden;
      border-radius: 2px;
      background:
        linear-gradient(180deg, transparent 10px, rgb(255 255 255 / 0.55) 10px 12px, transparent 12px calc(100% - 12px), rgb(255 255 255 / 0.55) calc(100% - 12px) calc(100% - 10px), transparent 0),
        linear-gradient(90deg, color-mix(in srgb, var(--c) 60%, #000), var(--c) 30%, var(--c) 65%, color-mix(in srgb, var(--c) 70%, #000));
      color: #fff;
      font-size: 8px;
      font-weight: 700;
      letter-spacing: 0.08em;
      text-transform: uppercase;
      white-space: nowrap;
      writing-mode: vertical-rl;
      backface-visibility: hidden;
    }

    // back cover (the -x side), hinged on the spine's left edge and turned to face left
    i:nth-child(2) {
      left: -$depth;
      width: $depth;
      background: color-mix(in srgb, var(--c) 55%, #000);
      transform-origin: 100% 50%;
      transform: rotateY(-90deg);
    }

    // front cover (the +x side): turned to face right, so the title reads from the spine outward
    i:nth-child(3) {
      left: 100%;
      display: grid;
      place-items: center;
      width: $depth;
      padding: 8px;
      border-radius: 0 3px 3px 0;
      background:
        radial-gradient(circle at 50% 36%, rgb(255 255 255 / 0.35) 0 12px, transparent 13px),
        linear-gradient(160deg, color-mix(in srgb, var(--c) 85%, #fff), color-mix(in srgb, var(--c) 70%, #000));
      color: #fff;
      text-align: center;
      transform-origin: 0 50%;
      transform: rotateY(90deg);
      backface-visibility: hidden;

      b {
        font-size: 9px;
        line-height: 1.15;
      }
    }

    // the top of the page block, laid flat and running back from the spine
    i:nth-child(4) {
      top: -$depth;
      left: 0;
      width: 100%;
      height: $depth;
      border: solid color-mix(in srgb, var(--c) 70%, #000);
      border-width: 0 2px 2px;
      background: repeating-linear-gradient(90deg, #f3ecdc 0 2px, #d9cfb8 2px 3px);
      transform-origin: 50% 100%;
      transform: rotateX(90deg);
    }
  }

  // Out toward you (translate) and a turn to show the cover (rotate), from a slot that stays put.
  &__slot:hover &__book,
  &__slot:focus-visible &__book {
    translate: 0 -5px $pull;
    rotate: y -36deg;
    transition:
      translate 0.4s cubic-bezier(0.3, 0.7, 0.4, 1),
      rotate 0.5s $ease 0.4s;
  }
}
Standalone snippet · plain CSS, no build step, no dependencies.