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.

  • 186 lines of CSS
  • 20 lines of HTML
  • No JavaScript
  • No dependencies
  • Snippet under MIT
GitHub

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. Every face is box-sizing: border-box, so the front cover’s padding and the page block’s border sit inside it: the covers are exactly the spine’s height and the pages’ depth.
  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. The one book that is out turns pointer-events back on: it is drawn in front of its neighbours’ slots, and a pointer moving onto it must stay on it, not land on the slot behind it.
  3. A forced hover (the checks make every :hover match, to judge the hover pose) holds :hover on every slot at once. .slot:hover:not(:has(~ .slot:hover)) pulls out only the last of them, so the held pose is one a pointer can make; with a real pointer only one slot is hovered and the rule reads as plain :hover.
  4. The book turns around its own middle: transform-origin: 50% 50% -36 units puts the pivot half its depth behind the spine.
  5. 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.
  6. Every length is a multiple of one base unit, --u, tied to the canvas, text included, so the shelf is the same share of a gallery card, the editor and a recording canvas. Each book's thickness and height come from its style attribute as plain numbers, and CSS multiplies them by --u.

Key techniques

  • cuboid from 4 faces sized by --t / --h
  • transform-origin at the book’s 3D centre
  • static slots, pointer-events: none books
  • separate translate / rotate properties 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:20; --h:112; --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:16; --h:98; --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:24; --h:122; --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:18; --h:92; --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:22; --h:106; --c:#5846b8">
      <div class="book"><i>Origin</i><i></i><i><b>Transform origin</b></i><i></i></div>
    </div>
  </div>
</div>

CSS 186 lines

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

/* 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: calc(3 * var(--u));
  padding: 0 calc(12 * var(--u));
  pointer-events: none;
  transform-style: preserve-3d;
  /* turned, the shelf reaches further out on the right than on the left, and a pulled-out book
     further still: move it left by that much, so it is centred */
  translate: calc(-15 * var(--u)) 0;
  transform: rotateX(-16deg) rotateY(-28deg);
}

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

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

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

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

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

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

/* the book turns around its own middle, 36 units behind the spine */
.book {
  position: absolute;
  inset: 0;
  pointer-events: none;
  transform-style: preserve-3d;
  transform-origin: 50% 50% calc(-36 * var(--u));
  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;
}

/* only the last slot that reads as hovered comes out: a pointer only ever hovers one, but a
   pose check forces :hover on all of them, and five books out at once is a pose no visitor
   can make (every book in the air, turned into its neighbour, the shelf left behind) */
.slot:hover:not(:has(~ .slot:hover)) .book,
.slot:focus-visible .book {
  /* the book that is out is drawn over its neighbours' slots, so it catches the pointer itself:
     otherwise moving onto it hovers the slot behind it and swaps it for a neighbour */
  pointer-events: auto;
  /* out further than the book is deep (72 units), so it is clear of its neighbours before it turns */
  translate: 0 calc(-5 * var(--u)) calc(86 * var(--u));
  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;
}

/* every face is exactly the size it is given: the front cover's padding and the page block's
   border are inside it (border-box), so the covers are the spine's height and the pages' depth, and
   nothing sticks out past the page block. The frame is the whole page, so no site stylesheet sets
   this for the model: it says it itself */
.book i {
  position: absolute;
  top: 0;
  box-sizing: border-box;
  height: 100%;
}

/* the spine */
.book i:nth-child(1) {
  left: 0;
  width: 100%;
  display: grid;
  place-items: center;
  overflow: hidden;
  border-radius: calc(2 * var(--u));
  background:
    linear-gradient(180deg, transparent calc(10 * var(--u)), rgb(255 255 255 / 0.55) calc(10 * var(--u)) calc(12 * var(--u)), transparent calc(12 * var(--u)) calc(100% - 12 * var(--u)), rgb(255 255 255 / 0.55) calc(100% - 12 * var(--u)) calc(100% - 10 * var(--u)), 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;
  /* a dark halo round the white words, so they read on the brightest spines and covers too: on the
     teal and the amber they were 1.6:1 */
  text-shadow: 0 0 calc(1 * var(--u)) rgb(0 0 0 / 0.75), 0 0 calc(3 * var(--u)) rgb(0 0 0 / 0.55);
  font-size: calc(8 * var(--u));
  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: calc(-72 * var(--u));
  width: calc(72 * var(--u));
  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: calc(72 * var(--u));
  padding: calc(8 * var(--u));
  border-radius: 0 calc(3 * var(--u)) calc(3 * var(--u)) 0;
  background:
    radial-gradient(circle at 50% 36%, rgb(255 255 255 / 0.35) 0 calc(12 * var(--u)), transparent calc(13 * var(--u))),
    linear-gradient(160deg, color-mix(in srgb, var(--c) 85%, #fff), color-mix(in srgb, var(--c) 70%, #000));
  color: #fff;
  text-shadow: 0 0 calc(1 * var(--u)) rgb(0 0 0 / 0.75), 0 0 calc(3 * var(--u)) rgb(0 0 0 / 0.55); /* see the spine */
  font-size: calc(9 * var(--u));
  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: calc(-72 * var(--u));
  left: 0;
  width: 100%;
  height: calc(72 * var(--u));
  border: solid color-mix(in srgb, var(--c) 70%, #000);
  border-width: 0 calc(2 * var(--u)) calc(2 * var(--u));
  background: repeating-linear-gradient(90deg, #f3ecdc 0 calc(2 * var(--u)), #d9cfb8 calc(2 * var(--u)) calc(3 * var(--u)));
  transform-origin: 50% 100%;
  transform: rotateX(90deg);
}
Standalone snippet · plain CSS, no build step, no dependencies.