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.

  • 110 lines of CSS
  • 13 lines of HTML
  • No JavaScript
  • No dependencies
  • MIT licensed

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.

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 110 lines

.scene {
  perspective: 800px;
}

.fan {
  display: flex;
  width: 156px;
  height: 132px;
  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: 0px; }
.fan-slot:nth-child(2) { --r: 0deg;  --z: 12px; }
.fan-slot:nth-child(3) { --r: 8deg;  --z: 24px; }

.fan-card {
  position: absolute;
  top: 6px;
  left: calc(50% - 42px);
  display: grid;
  align-content: start;
  gap: 6px;
  width: 84px;
  height: 120px;
  padding: 8px;
  border: 1px solid color-mix(in srgb, var(--c) 70%, transparent);
  border-radius: 11px;
  background: linear-gradient(
    160deg,
    color-mix(in srgb, var(--c) 46%, #141830),
    color-mix(in srgb, var(--c) 12%, #141830)
  );
  box-shadow: 0 12px 18px -12px #000;
  color: #eceefb;
  opacity: 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, 0px), var(--y, 0px), var(--z)) rotateY(var(--ry, 0deg)) rotateZ(var(--r));
  transition:
    transform 0.45s cubic-bezier(0.3, 1.3, 0.5, 1),
    opacity 0.3s;
}

.fan-card i {
  display: block;
  height: 42px;
  border-radius: 6px;
  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: 11px;
  line-height: 1;
}

.fan-card span {
  display: block;
  height: 4px;
  border-radius: 2px;
  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.5;
}

/* ...cards AFTER the active one lean away to the right... */
.fan-slot:hover ~ .fan-slot,
.fan-slot:focus-visible ~ .fan-slot {
  --x: 16px;
  --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: -16px;
  --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: 0px;
  --y: -6px;
  --z: 80px;
  --r: 0deg;
  --ry: 0deg;
}

Sass source 121 lines

// Three overlapping cards. The SLOTS are the hit targets: three static strips that tile the
// container without overlapping. Each card is a pointer-events:none child that may fly anywhere,
// so the thing under the pointer never moves and :hover can never flicker.
.d-hovercards {
  display: flex;
  width: 156px;
  height: 132px;
  transform-style: preserve-3d;
  // coplanar with its slots: only the slots may catch the pointer
  pointer-events: none;

  &__slot {
    position: relative;
    flex: 1;
    outline: none;
    cursor: pointer;
    pointer-events: auto;
    transform-style: preserve-3d;

    // rest pose: a fan, each card a little nearer than the one before so they never z-fight
    &:nth-child(1) {
      --r: -8deg;
      --z: 0px;
      --c: var(--accent);
    }

    &:nth-child(2) {
      --r: 0deg;
      --z: 12px;
      --c: var(--hot);
    }

    &:nth-child(3) {
      --r: 8deg;
      --z: 24px;
      --c: var(--accent-2);
    }
  }

  &__card {
    position: absolute;
    top: 6px;
    left: calc(50% - 42px);
    display: grid;
    align-content: start;
    gap: 6px;
    width: 84px;
    height: 120px;
    padding: 8px;
    border: 1px solid color-mix(in srgb, var(--c) 70%, transparent);
    border-radius: 11px;
    background: linear-gradient(
      160deg,
      color-mix(in srgb, var(--c) 46%, var(--surface-solid)),
      color-mix(in srgb, var(--c) 12%, var(--surface-solid))
    );
    box-shadow: 0 12px 18px -12px #000;
    color: var(--text);
    opacity: var(--o, 1);
    pointer-events: none;
    transform-origin: 50% 100%;
    // One transform, fed by variables: every state below only changes numbers.
    transform: translate3d(var(--x, 0px), var(--y, 0px), var(--z)) rotateY(var(--ry, 0deg)) rotateZ(var(--r));
    transition:
      transform 0.45s cubic-bezier(0.3, 1.3, 0.5, 1),
      opacity 0.3s;

    i {
      height: 42px;
      border-radius: 6px;
      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));
    }

    b {
      font-size: 11px;
      line-height: 1;
    }

    span {
      height: 4px;
      border-radius: 2px;
      background: color-mix(in srgb, var(--text) 28%, transparent);

      &:last-child {
        width: 60%;
      }
    }
  }

  // Something is active: every card dims...
  &:hover &__slot,
  &:focus-within &__slot {
    --o: 0.5;
  }

  // ...cards AFTER the active one lean away to the right...
  &__slot:hover ~ &__slot,
  &__slot:focus-visible ~ &__slot {
    --x: 16px;
    --ry: 26deg;
  }

  // ...cards BEFORE it lean away to the left...
  &__slot:has(~ &__slot:hover),
  &__slot:has(~ &__slot:focus-visible) {
    --x: -16px;
    --ry: -26deg;
  }

  // ...and the active one (last, so it wins) straightens and comes toward the viewer.
  & &__slot:is(:hover, :focus-visible) {
    --o: 1;
    --x: 0px;
    --y: -6px;
    --z: 80px;
    --r: 0deg;
    --ry: 0deg;
  }
}
Standalone snippet · plain CSS, no build step, no dependencies.