Pure CSS Cards & galleries #loop

Card fan in pure CSS

Five cards share one pivot below the hand and fan out by index.

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

How it works

  1. All five cards sit on the same spot and share one pivot. Even gathered they are spread 7° a card, so every corner index shows and a paused card reads as a hand, not a single card.
  2. transform-origin: 50% 150% moves the pivot below the card, so rotateZ swings it along an arc instead of spinning it in place.
  3. The index runs −2…2, so rotateZ(calc(var(--i) * 15deg)) fans symmetrically around an upright middle card, and the loop swings between that and the gathered 7°.
  4. A few units of translateZ per card gives each its own depth, which avoids flicker where they overlap.
  5. Every length is a multiple of one base unit, --u, so the hand is the same share of a gallery card, the editor and a recording canvas.
  6. How far below the card the pivot sits is what decides the width: the further down, the flatter the arc and the wider the spread. At 170% the open hand was a third wider than the canvas, so the pivot came up to 150% and 15° fans into an arc the band can hold. The outer cards also swing downward, so the hand is lifted 12 units with translateY and the open hand and the gathered one share the miss rather than one of them being centred and the other not.

Key techniques

  • transform-origin far below the card
  • rotateZ(var(--i) × step)
  • translateZ per card to avoid z-fighting

Copy-paste code

HTML 9 lines

<div class="scene">
  <div class="hand">
    <i style="--i:-2">A<small></small></i>
    <i style="--i:-1">K<small></small></i>
    <i style="--i:0">Q<small></small></i>
    <i style="--i:1">J<small></small></i>
    <i style="--i:2">10<small></small></i>
  </div>
</div>

CSS 51 lines

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

.hand {
  position: relative;
  width: calc(110 * var(--u));
  height: calc(156 * var(--u));
  transform-style: preserve-3d;
  /* the outer cards swing down as well as out, and their shadows hang lower still: the lift
     splits the difference between the closed hand and the open one, so neither sits off centre */
  transform: translateY(calc(-12 * var(--u))) rotateX(26deg);
}

.hand i {
  position: absolute;
  inset: 0;
  padding: calc(8 * var(--u)) calc(12 * var(--u));
  border-radius: calc(10 * var(--u));
  background: linear-gradient(160deg, #fff, #d9dcec);
  color: #14172b;
  font: 800 calc(26 * var(--u))/1 Inter, system-ui;
  box-shadow: 0 calc(8 * var(--u)) calc(16 * var(--u)) calc(-8 * var(--u)) #000;
  /* the pivot is half a card below the bottom edge. Further down flattens the arc and throws
     the outer cards wider — at 170% the open hand ran off the sides of the canvas. */
  transform-origin: 50% 150%;
  animation: fan 3s ease-in-out infinite alternate;
}

/* the spade under each index is drawn, not typed: ♠ is in neither font an export carries, so every
   device, and the export service, drew a spade of its own. One shape, as a mask, in the card's ink */
.hand small {
  display: block;
  width: calc(13 * var(--u));
  height: calc(14 * var(--u));
  margin-top: calc(4 * var(--u));
  background: currentColor;
  -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 22'%3E%3Cpath d='M10 0C6 5 0 8 0 12.5 0 15.6 2.4 17.6 5 17.6c1.8 0 3.3-.8 4.2-2-.3 2.4-1.2 4.2-2.7 5.9h7c-1.5-1.7-2.4-3.5-2.7-5.9.9 1.2 2.4 2 4.2 2 2.6 0 5-2 5-5.1C20 8 14 5 10 0z'/%3E%3C/svg%3E") center / contain no-repeat;
  mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 22'%3E%3Cpath d='M10 0C6 5 0 8 0 12.5 0 15.6 2.4 17.6 5 17.6c1.8 0 3.3-.8 4.2-2-.3 2.4-1.2 4.2-2.7 5.9h7c-1.5-1.7-2.4-3.5-2.7-5.9.9 1.2 2.4 2 4.2 2 2.6 0 5-2 5-5.1C20 8 14 5 10 0z'/%3E%3C/svg%3E") center / contain no-repeat;
}

@keyframes fan {
  /* never closed flat: even the gathered hand is spread 7deg a card, enough to show every corner index, so a paused card shows a fan */
  0%, 15%   { transform: translateZ(calc(var(--i) * 1 * var(--u))) rotateZ(calc(var(--i) * 7deg)); }
  85%, 100% { transform: translateZ(calc(var(--i) * 6 * var(--u))) rotateZ(calc(var(--i) * 15deg)); }
}
Standalone snippet · plain CSS, no build step, no dependencies.