3D Flip Card in CSS: Perspective, backface-visibility, Hover Reveal
Build a smooth 3D flip card in pure CSS. Master perspective, backface-visibility, and transform-style to reveal content on hover — no JavaScript needed.
What's Actually Happening When a Card Flips
A CSS 3D flip card isn't magic. It's three properties working together: perspective on the parent, transform-style: preserve-3d on the card container, and backface-visibility: hidden on each face. Miss any one of those three, and you get a broken flat transition, a face showing through the wrong side, or nothing at all.
The mental model that helps: imagine you're looking at a physical card through a window. perspective controls how far that window is from the card — set it to 600px and the 3D effect is dramatic, set it to 2000px and it's barely noticeable. The perspective property belongs on the *parent*, not the element you're rotating. That mistake trips up a lot of devs who've only seen tutorials that skip the why.
Worth noting: transform-style: preserve-3d tells the browser to render child elements in 3D space instead of flattening them into the parent's 2D plane. Without it, the front and back faces collapse into the same layer and you lose the whole effect. You need it on the card wrapper — the thing you actually rotate — not on the faces themselves.
One more thing — backface-visibility: hidden is what makes the back face invisible when it's pointing away from you. Without it, you'd see the back face bleeding through the front face during the transition. Both .card-front and .card-back need this property. Yes, both.
The HTML Structure
Keep it simple. Three elements: a scene (holds the perspective), a card (the thing that rotates), and two faces. That's it. Don't nest extra wrappers thinking they'll help — they usually just create stacking context headaches.
<div class="scene">
<div class="card">
<div class="card-face card-front">
<h2>Hover Me</h2>
</div>
<div class="card-face card-back">
<p>Secret content revealed.</p>
</div>
</div>
</div>The scene div is purely structural — it's the perspective container and sets the card's dimensions. The card div is what you rotate on hover. The two faces are absolutely positioned on top of each other, which is why you need the card to have position: relative and the faces to have position: absolute with width: 100% and height: 100%.
Honestly, the structure feels like overkill at first for something that looks this simple. But if you try to flatten it — rotating the face itself instead of a wrapper — you'll immediately hit a wall where the back content has no clean way to be positioned behind the front.
The CSS That Makes It Work
Here's a complete working implementation. No libraries, no frameworks — pure CSS, works back to Chrome 36 (2014). The only tricky part is the back face: it starts rotated 180deg so it's pre-flipped, and the hover brings both together.
.scene {
width: 300px;
height: 200px;
perspective: 800px;
}
.card {
width: 100%;
height: 100%;
position: relative;
transform-style: preserve-3d;
transition: transform 0.6s ease;
cursor: pointer;
}
.scene:hover .card {
transform: rotateY(180deg);
}
.card-face {
position: absolute;
width: 100%;
height: 100%;
backface-visibility: hidden;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
}
.card-front {
background: #1a1a2e;
color: #fff;
}
.card-back {
background: #16213e;
color: #e0e0e0;
transform: rotateY(180deg);
}The perspective: 800px on .scene is a solid default for card-sized elements. Go lower — say 400px — and it starts looking like a fisheye lens. Go above 1200px and the flip reads more like a 2D transition than a 3D one. In practice, 600px–900px is the sweet spot for UI cards.
The transition: transform 0.6s ease is on .card, not on the hover state. That way it transitions both on hover-in and hover-out. Put the transition on .scene:hover .card instead, and the return animation will snap back instantly — a classic gotcha.
Quick aside: if you want the flip to go vertically instead of horizontally, swap rotateY(180deg) for rotateX(180deg) in both the hover state and the back face starting transform. Everything else stays identical.
Fixing the Safari backface-visibility Bug
Safari has had a longstanding bug where backface-visibility doesn't play nicely with certain stacking contexts. In Safari up through version 17, you'll sometimes see the back face showing through — a ghost of the rear content visible on the front side.
The fix is to add -webkit-backface-visibility: hidden alongside the unprefixed version. Yes, in 2026. Safari. Anyway:
.card-face {
backface-visibility: hidden;
-webkit-backface-visibility: hidden;
/* Also helps with subpixel rendering glitches */
-webkit-transform-style: preserve-3d;
}There's also a separate bug where Safari sometimes flickers during the transition. Adding transform: translateZ(0) to .card-face forces GPU compositing and usually kills the flicker. It's a 1-line fix that has saved me hours of debugging across client projects.
Look, if you're shipping production code that needs to work on iPhone Safari, test it on a real device. The simulator doesn't always reproduce the backface bleed. A real iPhone 14 or 15 running Safari 17 will catch issues the Mac simulator misses.
Adding Glassmorphism or Dark Styling to the Faces
A plain colored card works, but you're probably here because you want something that looks sharp. Glassmorphism on a flip card is a natural pairing — frosted front, solid-dark back for contrast. If you want pre-built glass components to drop straight into your project, check out the glassmorphism components on Empire UI rather than hand-rolling the blur values.
.card-front {
background: rgba(255, 255, 255, 0.08);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.15);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
border-radius: 16px;
color: #fff;
}
.card-back {
background: rgba(10, 10, 30, 0.85);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 16px;
color: #e0e0e0;
transform: rotateY(180deg);
}That said, backdrop-filter can tank performance on lower-end Android devices if you have many cards on the page at once. For a grid of 12+ cards, consider a solid fallback background and only apply the blur on hover using a class swap — or just use a solid semi-transparent dark background by default.
If you want to tune the glass values without trial-and-error, the glassmorphism generator lets you preview background blur, opacity, and border combinations live. Much faster than tweaking CSS and reloading manually.
Click-to-Flip With a Single Line of JavaScript
Hover-based flipping works great on desktop, but it's invisible on mobile — there's no hover state on a touchscreen. For a production card component, you want click-to-flip as well. You don't need a framework for this.
// Grab all cards on the page
document.querySelectorAll('.card').forEach(card => {
card.addEventListener('click', () => {
card.classList.toggle('is-flipped');
});
});Then in CSS, add the toggled state alongside your hover state:
.scene:hover .card,
.card.is-flipped {
transform: rotateY(180deg);
}That's it. Click toggles the class, class triggers the same CSS transform your hover state already uses. No state management library. No React component. Just a toggle class and the CSS you already wrote. If you're building in React or Vue, the same pattern works — hold a boolean in state and conditionally apply the class.
Performance, Accessibility, and Gotchas
3D transforms are GPU-accelerated, which is good. But if you've got 40 flip cards visible simultaneously, you're creating 40 separate GPU compositing layers. Use them where they add value — profile card grids, pricing tables, feature highlights — not as a default treatment for every bit of content.
Accessibility matters here. The content on the back face is technically in the DOM, but visually hidden when facing away. Screen readers will read both faces regardless of the visual state. If the back face contains distinct content (not just a stylistic flip of the same info), make sure it's semantically meaningful in linear order, or use aria-hidden on whichever face is currently not visible and toggle it with JavaScript.
card.addEventListener('click', () => {
const isFlipped = card.classList.toggle('is-flipped');
card.querySelector('.card-front').setAttribute('aria-hidden', isFlipped);
card.querySelector('.card-back').setAttribute('aria-hidden', !isFlipped);
});Worth noting: will-change: transform can pre-promote the card to its own GPU layer, which reduces jank on the first interaction. Use it sparingly — one or two hero cards, not a full grid. Slapping will-change on everything is one of those things that feels like an optimization but actually eats memory and can hurt overall page performance.
And if you want inspiration for pairing a flip card UI with bolder visual styles, the box shadow generator is a quick way to add depth to the card edges without guessing at values. Strong shadows on a 3D-flipping card make the depth effect feel physically grounded — especially at 300px+ card widths.
FAQ
You're missing backface-visibility: hidden on one or both faces. Add it to both .card-front and .card-back. Also add -webkit-backface-visibility: hidden for Safari. Both faces need it, not just the back.
Check where you put perspective — it needs to be on the parent container (the scene wrapper), not on the card itself. Also confirm transform-style: preserve-3d is on the card div, not the faces. A perspective value between 600px and 900px gives the most natural depth for standard card sizes.
Replace every rotateY(180deg) with rotateX(180deg) — that's the hover state on the card and the starting transform on the back face. Everything else in your CSS stays the same.
Add a click event listener that toggles a class on the card, then target that class in CSS alongside your hover selector: .scene:hover .card, .card.is-flipped { transform: rotateY(180deg); }. One event listener, one CSS rule — covers both desktop hover and mobile tap.