Pure CSS Cards & galleries #hover #cards #ui

Hover card fan in pure CSS

Three overlapping cards: the one you point at straightens and comes toward you while the others dim and lean away. Static strips catch the pointer, so nothing flickers.

  • 117 lines of CSS
  • 13 lines of HTML
  • No JavaScript
  • No dependencies
  • Snippet under MIT
GitHub

How it works

  1. Three slots are the hit targets, tiled edge to edge; the cards inside them are pointer-events: none so the pointer always lands on a slot, never on a moving card.
  2. Every state a card can be in — resting, dimmed, leaning left, leaning right, active — only changes a handful of custom properties fed into one transform.
  3. :has() answers "is a later sibling hovered?" so the cards before the active one can lean away too, not just the ones after it.
  4. Each card rests at a different translateZ so the overlapping fan never z-fights, and the active card jumps to the front on top of that.
  5. Every length is a multiple of one base unit, --u, tied to the canvas, text included, so the fan is the same share of a gallery card, the editor and a recording canvas.

Key techniques

  • static hit strips + pointer-events: none cards
  • one transform fed by custom properties
  • :has() for "siblings before"
  • translateZ per card (never coplanar)

Copy-paste code

HTML 13 lines

<div class="scene">
  <div class="fan">
    <div class="fan-slot" tabindex="0">
      <div class="fan-card" style="--c:#8b6cff"><i></i><b>Design</b><span></span><span></span></div>
    </div>
    <div class="fan-slot" tabindex="0">
      <div class="fan-card" style="--c:#ff4d9d"><i></i><b>Build</b><span></span><span></span></div>
    </div>
    <div class="fan-slot" tabindex="0">
      <div class="fan-card" style="--c:#2ee6d6"><i></i><b>Ship</b><span></span><span></span></div>
    </div>
  </div>
</div>

CSS 117 lines

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

.fan {
  display: flex;
  width: calc(156 * var(--u));
  height: calc(132 * var(--u));
  transform-style: preserve-3d;
  /* coplanar with its slots: only the slots may catch the pointer */
  pointer-events: none;
}

.fan-slot {
  position: relative;
  flex: 1;
  outline: none;
  cursor: pointer;
  pointer-events: auto;
  transform-style: preserve-3d;
}

/* rest pose: each card a little nearer than the one before so they never z-fight */
.fan-slot:nth-child(1) { --r: -8deg; --z: 0; }
.fan-slot:nth-child(2) { --r: 0deg;  --z: calc(12 * var(--u)); }
.fan-slot:nth-child(3) { --r: 8deg;  --z: calc(24 * var(--u)); }

.fan-card {
  position: absolute;
  top: calc(6 * var(--u));
  left: calc(50% - 42 * var(--u));
  display: grid;
  align-content: start;
  gap: calc(6 * var(--u));
  /* 84 × 120 outside, padding and border included: centred in its slot, 6 units clear top and bottom */
  box-sizing: border-box;
  width: calc(84 * var(--u));
  height: calc(120 * var(--u));
  padding: calc(8 * var(--u));
  border: calc(1 * var(--u)) solid color-mix(in srgb, var(--c) 70%, transparent);
  border-radius: calc(11 * var(--u));
  background: linear-gradient(
    160deg,
    color-mix(in srgb, var(--c) 46%, #141830),
    color-mix(in srgb, var(--c) 12%, #141830)
  );
  box-shadow: 0 calc(12 * var(--u)) calc(18 * var(--u)) calc(-12 * var(--u)) #000;
  color: #eceefb;
  /* the cards not pointed at dim by brightness, not opacity: see-through, on the light stage their
     words were 1.6:1; darkened, they stay on their own dark card on either stage */
  filter: brightness(var(--o, 1));
  pointer-events: none;
  transform-origin: 50% 100%;
  /* one transform, fed by variables: every state below only ever changes numbers */
  transform: translate3d(var(--x, 0), var(--y, 0), var(--z)) rotateY(var(--ry, 0deg)) rotateZ(var(--r));
  transition:
    transform 0.45s cubic-bezier(0.3, 1.3, 0.5, 1),
    filter 0.3s;
}

.fan-card i {
  display: block;
  height: calc(42 * var(--u));
  border-radius: calc(6 * var(--u));
  background:
    radial-gradient(circle at 70% 30%, rgb(255 255 255 / 0.55), transparent 45%),
    linear-gradient(135deg, var(--c), color-mix(in srgb, var(--c) 40%, #000));
}

.fan-card b {
  font-size: calc(11 * var(--u));
  line-height: 1;
}

.fan-card span {
  display: block;
  height: calc(4 * var(--u));
  border-radius: calc(2 * var(--u));
  background: color-mix(in srgb, #eceefb 28%, transparent);
}

.fan-card span:last-child {
  width: 60%;
}

/* something is active: every card dims... */
.fan:hover .fan-slot,
.fan:focus-within .fan-slot {
  --o: 0.7;
}

/* ...cards AFTER the active one lean away to the right... */
.fan-slot:hover ~ .fan-slot,
.fan-slot:focus-visible ~ .fan-slot {
  --x: calc(16 * var(--u));
  --ry: 26deg;
}

/* ...cards BEFORE it lean away to the left (:has() looks forward for a hovered sibling)... */
.fan-slot:has(~ .fan-slot:hover),
.fan-slot:has(~ .fan-slot:focus-visible) {
  --x: calc(-16 * var(--u));
  --ry: -26deg;
}

/* ...and the active one (this rule is last, so it wins) squares up and comes toward you */
.fan .fan-slot:is(:hover, :focus-visible) {
  --o: 1;
  --x: 0;
  --y: calc(-6 * var(--u));
  --z: calc(80 * var(--u));
  --r: 0deg;
  --ry: 0deg;
}
Standalone snippet · plain CSS, no build step, no dependencies.