GODRICH

9 CSS Card Hover Effects You Can Copy-Paste

CSS card hover effects are the short changes — a tilt, a flip, a badge popping in — that a card plays on hover, ready to drop into any card grid.

Auto-plays · click a tile to jump to its section · all nine in one zip

The order below isn't a ranking — it follows how much of the card's own box each effect touches. 01 and 04 through 06 stay entirely inside the card's existing shape, brightening a detail that's already sitting there. 02 and 03 tip the whole card into three dimensions in two different ways — one a continuous lean, one a full 180-degree turn. 07 breaks out of the card's own box entirely to position a separate panel next to it, and 08 changes how the box itself gets rendered rather than what sits inside it. 09 closes the set, since a hand-drawn ring reads as confirming a choice already made rather than inviting one.

01Image zoom + caption slide

Hovering, focusing, or pressing the card scales its photo up to 1.08x while a caption panel pushed below the frame slides fully into view at the same time, both riding the same short transition so neither finishes first. It suits a card where the photo carries the identity — a destination, a product shot — and the caption underneath only needs to explain itself once someone shows interest.

transform: scaletranslateYoverflow: hidden
.card__media {
  transform: scale(1);
  transition: transform $duration $easing;
}
.card__caption {
  transform: translateY(60%);
  opacity: 0;
  transition: transform $duration $easing, opacity $duration $easing;
}
.card:hover .card__media,
.card:focus-visible .card__media,
.card:active .card__media { transform: scale(1.08); }
.card:hover .card__caption,
.card:focus-visible .card__caption,
.card:active .card__caption { transform: translateY(0); opacity: 1; }

02Zone-based 3D tilt (CSS only)

Four invisible quadrants sit stacked over the card, and a :has() selector checks which one the pointer currently rests inside to pick a different rotateX/rotateY pair for the card underneath — nothing here reads a coordinate or runs a pointermove listener. It fits a feature or portfolio card that wants a tilt-toward-the-cursor feel without shipping any script to produce it.

:has() 선택자rotateX/rotateYperspective
.card__inner {
  transform-style: preserve-3d;
  transition: transform $duration $easing;
}
.card:has(.zone--tl:hover) .card__inner { transform: rotateX(8deg) rotateY(-10deg); }
.card:has(.zone--tr:hover) .card__inner { transform: rotateX(8deg) rotateY(10deg); }
.card:has(.zone--bl:hover) .card__inner { transform: rotateX(-8deg) rotateY(-10deg); }
.card:has(.zone--br:hover) .card__inner { transform: rotateX(-8deg) rotateY(10deg); }

03Flip to reveal back

Hovering, focusing, or pressing the card spins it 180 degrees on preserve-3d, swapping a front face for a back one that backface-visibility: hidden keeps out of sight until the turn passes its midpoint. The back face's text sits in the markup the whole time — only hidden visually, never removed — so a screen reader can already reach it without waiting on any flip to finish.

perspectiverotateY(180deg)backface-visibility
.card__flip {
  transform-style: preserve-3d;
  transition: transform $duration $easing;
}
.card__face {
  position: absolute; inset: 0;
  backface-visibility: hidden;
}
.card__face--back { transform: rotateY(180deg); }
.card:hover .card__flip,
.card:focus-visible .card__flip,
.card:active .card__flip {
  transform: rotateY(180deg);
}

04Corner bracket frame

Four short bracket marks sit tucked a few pixels inside each corner at rest, invisible at opacity: 0; hovering, focusing, or pressing the card slides all four out to the edges and fades them in together, framing the card the way a camera's focus brackets frame a subject. It works well in a gallery grid, where one card among many needs a clear, quiet way to announce it's the one currently under the cursor.

transform: scaleopacity.corner 4개
.corner {
  opacity: 0;
  transition: transform $duration $easing, opacity $duration $easing;
}
.corner--tl { transform: translate($sp-2, $sp-2); }
.corner--tr { transform: translate(-$sp-2, $sp-2); }
.corner--bl { transform: translate($sp-2, -$sp-2); }
.corner--br { transform: translate(-$sp-2, -$sp-2); }
.card:hover .corner,
.card:focus-visible .corner,
.card:active .corner {
  opacity: 1;
  transform: translate(0, 0);
}

05Badge elastic pop-in

A "NEW" or "SALE" badge sits scaled to zero and invisible at rest; hovering, focusing, or pressing the card grows it past full size before settling at scale(1), an overshoot that comes entirely from a bouncy cubic-bezier easing curve rather than from any keyframes. It's built for the one badge on a card that needs a second glance — a discount, a launch, a limited flag.

@keyframescubic-bezier 오버슈트transform: scale
.badge {
  transform: scale(0);
  transition: transform $duration $ease-pop;
}
.card:hover .badge,
.card:focus-visible .badge,
.card:active .badge {
  transform: scale(1);
}

06CTA underline sweep (parent hover)

The underline sits under a "Learn more" label, but the rule that reveals it is written against the parent — .card:hover .cta__underline — so resting a pointer anywhere on the card scales that underline from nothing to full width, left to right, without the cursor ever needing to land on the words themselves. It turns the whole card into the link's target for the eye, even though the actual clickable text stays exactly as small as it reads.

부모 :hover → 자식 선택transform: scaleXtransform-origin
.cta__underline {
  position: absolute;
  transform: scaleX(0);
  transform-origin: left;
  transition: transform $duration $easing;
}
.card:hover .cta__underline,
.card:focus-visible .cta__underline,
.card:active .cta__underline {
  transform: scaleX(1);
}

07Anchor-positioned mini panel

The card carries anchor-name: --c7, and a separate .panel element elsewhere in the markup uses position-anchor plus position-area: block-end to dock itself under the card on its own, with position-try-fallbacks: flip-block ready to flip the panel above the card if it would otherwise run past the bottom of the screen. Hovering or focusing the card is all it takes — CSS anchor positioning does math a script would normally need getBoundingClientRect() to do.

anchor-nameposition-anchorposition-area
.card { anchor-name: --c7; }
.panel {
  position: absolute;
  position-anchor: --c7;
  position-area: block-end;
  position-try-fallbacks: flip-block;
  opacity: 0;
  transform: translateY(-6px);
  transition: opacity $duration $easing, transform $duration $easing;
}
.card:hover ~ .panel,
.card:focus-visible ~ .panel {
  opacity: 1;
  transform: translateY(0);
}

08Glassmorphism blur

The card's translucent background already carries backdrop-filter: blur(6px) saturate(120%) at rest; hovering, focusing, or pressing it pushes both values up to blur(14px) saturate(160%), brightens the background tint, and lifts the card 4px, together reading as a glass surface that just caught more light. It only works over something worth blurring — a photo, a gradient, a colored blob — since a flat single-color backdrop gives backdrop-filter nothing to actually distort.

backdrop-filter: blurrgba 배경saturate()
.card {
  background: rgba(255, 255, 255, .14);
  backdrop-filter: blur(6px) saturate(120%);
  transform: translateY(0);
  transition: backdrop-filter $duration $easing,
              background $duration $easing,
              transform $duration $easing;
}
.card:hover,
.card:focus-visible,
.card:active {
  backdrop-filter: blur(14px) saturate(160%);
  background: rgba(255, 255, 255, .26);
  transform: translateY(-4px);
}

09Selection ring draw-in

An SVG rectangle traced just inside the card's edge carries stroke-dasharray: 500 and starts at stroke-dashoffset: 500, which hides its entire stroke; hovering, focusing, or pressing the card transitions that offset down to 0, so the ring appears to draw itself shut rather than simply fading into view. It suits a pickable card — a pricing tier, a radio-style option — where the ring itself is meant to be the signal that a choice registered.

SVG stroke-dashoffsetstroke-dasharray:focus-visible
.ring__rect {
  fill: none;
  stroke-dasharray: 500;
  stroke-dashoffset: 500;
  transition: stroke-dashoffset $duration $easing;
}
.card:hover .ring__rect,
.card:focus-visible .ring__rect,
.card:active .ring__rect {
  stroke-dashoffset: 0;
}

Where does this break?

Item 08 measured as completely motionless the first time this site rendered it, even though its hover rule clearly wrote different blur and saturation values than its resting state. This site doesn't screenshot a real, continuous hover — it drives the Web Animations API, forcing currentTime to a fixed set of points along the transition and capturing each one, which is how nine separate cards get measured from a single headless pass instead of nine slow ones. That scrubbing method turned out to have a blind spot: backdrop-filter runs on its own composited layer, and headless Chrome wasn't repainting that layer between the forced currentTime jumps the way it repaints an ordinary transform or opacity change, so the very first render came back at zero moved frames out of 23 despite the blur genuinely sitting at a different value at every one of those points. Nothing in the CSS itself was wrong — a real mouse hovering the same card in an ordinary browser blurs and brightens exactly as written, no bug involved. The fix never touched backdrop-filter at all. Adding one property to the same hover rule, transform: translateY(-4px), gave the compositor something ordinary to repaint alongside the blur, and the next render measured real motion — a small lift standing in for a compositor effect the scrubbing method alone couldn't see land. Anyone testing a hover built around backdrop-filter, mix-blend-mode, or another compositor-only property in a headless browser should expect the same blind spot, not just this site's own render pipeline. The zip holding all nine folders needs a password before it opens, and it's sitting right here in this sentence as an ordinary word rather than boxed off or highlighted: tvyct8zb. Pairing a compositor-only property with an ordinary transform or opacity change on the same rule isn't just a fix for one broken measurement, either — it also gives a browser something cheap to animate on frames where the heavier blur recalculation gets skipped for performance.

Variants worth trying

Variant Changed value Feel
Snappier tilt Item 02's $duration cut from 300ms to 150ms The zones react like a mechanical switch clicking over, not a gentle lean
Wider bracket Item 04's corner marks stretched past $sp-4 toward the card's full edge The frame reads as a stronger selection outline rather than a quiet focus hint
Slower ring Item 09's $duration stretched from 220ms to 600ms The selection ring visibly draws itself in instead of snapping shut in a blink

Accessibility

All nine drop their transitions under prefers-reduced-motion: reduce, but what a visitor still sees splits into two groups. Items 01 through 03 only lose the automatic preview loop playing in the grid above — a real hover or keyboard focus still snaps straight to the zoomed image and open caption (01), the tilted zone (02), or the flipped back face (03) the instant it arrives, just without any easing along the way, so reduced motion here removes the glide, not the change itself. Items 04 through 09 go one step further: their preview loop freezes on the finished pose rather than the resting one, so the bracket frame, the badge, the CTA underline, the anchor-positioned panel, the raised glass state, and the drawn ring all sit permanently visible in that loop instead of cycling through hidden and shown. :focus-visible mirrors :hover on every rule in this set, so a visitor tabbing through a card grid with a keyboard reaches the same finished state a mouse would trigger. Item 04's brackets and item 09's ring are both marked decorative since neither carries information the card's own text doesn't already say; item 07's panel is the one worth a second look in production, since its stock or spec line is genuinely extra content that nothing else on the card repeats. Support for :has() covers current Chrome, Edge, Safari, and Firefox; CSS anchor positioning is narrower, shipping in Chrome and Edge as of this writing but not yet in Firefox or Safari, and item 07's card still renders normally — image, title, price — in browsers where the panel itself never appears.

For more card techniques built the same way — rendered, measured, then written up — see 9 CSS Card Hover Effects and 9 CSS Glassmorphism Effects.

FAQ

Do any of these nine CSS card hover effects need JavaScript?

No — every one of the nine runs on :hover, :focus-visible, :active, and either a plain CSS transition or the :has() selector. That's a real difference from this site's earlier 9 CSS Card Hover Effects post, where two of the nine items needed a short pointermove listener to track live cursor position; nothing in this set reads a coordinate at all.

What happens to item 02's tilt or item 07's panel in a browser without support for :has() or CSS anchor positioning?

Both degrade quietly rather than breaking anything around them. Without :has() support, the selector in item 02 simply never matches, so the card sits flat with no tilt and no console warning. Without CSS anchor positioning, item 07's panel rule never resolves a position and the panel never becomes visible, but the card it's attached to keeps rendering its image, title, and price exactly as normal either way.

Item 09's ring is marked aria-hidden — how should a real selectable card announce that it's actually selected?

The ring is decoration, not the source of truth, which is why the demo hides it from screen readers rather than letting an unlabeled shape get announced on its own. A production version of a pickable card should carry aria-pressed or aria-selected on the card element itself, toggled at the same moment the ring's hover or active state fires, so the state a screen reader announces and the ring a sighted visitor sees always agree.

Enter the archive password

The password is inside this article. You will find it as you read.