A flip card looks like it needs JavaScript — something has to swap the front content for the back content, right? It doesn't. Three CSS properties, used together, do the whole thing: perspective, transform-style: preserve-3d, and backface-visibility: hidden. Get any one of them wrong and the effect breaks in a specific, recognizable way — this is a breakdown of the real, working version and the two mistakes that account for almost every broken flip card you'll find in the wild.
perspective goes on the outer wrapper — the element that never rotates. It sets a virtual viewing distance in pixels, which is what makes the rotation look three-dimensional instead of like the card is just being squashed horizontally. A smaller value (400–600px) reads as a more dramatic, close-up rotation; a larger value (1200px+) reads as a subtler, farther-away one.
transform-style: preserve-3d goes on the element that actually rotates — the "inner" wrapper holding both faces. Without it, the browser flattens the 3D transforms of its children into 2D before rendering, which silently strips the 3D effect from both faces even though the rotation is technically still happening.
backface-visibility: hidden goes on both faces individually, not on the wrapper. Each face is a full, opaque, absolutely-positioned box stacked directly on top of the other. Without this property, once the rotation passes 90 degrees, the browser keeps rendering the front face's backside — mirrored, since you're now looking at the back of it — instead of hiding it so the actual back face shows through.
.flip-wrapper {
perspective: 600px; /* on the OUTER element */
width: 300px; height: 200px;
}
.flip-inner {
position: relative;
width: 100%; height: 100%;
transform-style: preserve-3d; /* on the ROTATING element */
transition: transform 0.5s ease;
}
.flip-wrapper:hover .flip-inner {
transform: rotateY(180deg);
}
.flip-front,
.flip-back {
position: absolute; inset: 0;
border-radius: 12px;
backface-visibility: hidden; /* on EACH FACE */
}
.flip-back {
transform: rotateY(180deg); /* pre-rotated, so it faces forward after the flip */
background: linear-gradient(135deg, #6366f1, #a78bfa);
}
That last rule is the part people miss when building this from scratch instead of copying a working example: the back face is pre-rotated 180 degrees in its resting state. When the parent flips 180 degrees, the back face's own 180-degree rotation cancels out, so it ends up facing the viewer right-side-up instead of appearing backwards.
perspective Has to Be on the Wrapper, Not the CardThis is the single most common reason a flip card "doesn't look 3D" — perspective got applied to .flip-inner (the element being rotated) instead of .flip-wrapper (its parent). perspective defines the 3D viewing context for an element's children, not for itself. Put it on the element that's rotating and it has no visible effect at all; the rotation still happens, but flattened, because there's no perspective context established for it to render into.
If you ever see a flip card that briefly shows a backwards, mirrored version of the front face mid-rotation before the real back face appears, backface-visibility: hidden is missing from at least one face. Both faces occupy the exact same space via position: absolute; inset: 0 — the whole illusion depends on the browser hiding whichever face is currently oriented away from the viewer. Skip this property and both faces stay visible simultaneously for part of the rotation, which is what produces that ghosting artifact.
This exact flip card, plus 15 other card patterns (glassmorphism, neumorphism, hover-lift, slide-reveal) with live previews and copy-ready code, is in UIXDraft's free CSS card gallery.
See the full card gallery →The :hover trigger in the example above is a reasonable default for a decorative, desktop-oriented flourish, but touch devices don't have a real hover state — a phone's browser typically fires :hover on tap and then gets stuck in a half-triggered state, or ignores it entirely depending on the browser. For a flip card that needs to actually work for every visitor, trigger it with a click handler that toggles a class instead, and keep the CSS transition exactly as-is:
<div class="flip-wrapper" onclick="this.classList.toggle('flipped')">
<div class="flip-inner">...</div>
</div>
<style>
.flip-wrapper.flipped .flip-inner {
transform: rotateY(180deg);
}
</style>
This also fixes an accessibility gap the hover-only version has: a keyboard user tabbing through the page can't trigger :hover at all. Pair the click handler with a tabindex="0" and a keydown listener for Enter/Space if the back face contains information a visitor actually needs, not just a decorative extra.
Everything above uses rotateY(), which flips the card around a vertical axis — like a book cover opening left-to-right. Swapping to rotateX() flips it around a horizontal axis instead, top-to-bottom. The properties involved don't change at all; only the axis in the transform and the direction the pre-rotated back face needs to compensate for does. A vertical flip (rotateY) generally reads as more natural for wide cards, and a horizontal flip (rotateX) for tall, portrait-oriented ones.
That's a missing backface-visibility: hidden on the front face. Without it, the browser keeps rendering the front face even after it's rotated past 90 degrees, so you briefly see its mirror image through the back of the card before the back face fully takes over.
perspective is either missing or set on the wrong element. It has to be on the outer wrapper, not the element that actually rotates — perspective defines the viewing distance for its children's 3D transforms, so applying it to the rotating element itself has no effect.
Hover-only breaks on touch devices, which have no hover state — a phone user can tap the card, but nothing happens, or the flip triggers and immediately reverts on tap-away. For anything beyond a decorative desktop-only flourish, trigger the flip with a click/tap handler that toggles a class, and keep :hover only as a progressive enhancement for pointer devices.