EmpireUI
Get Pro
← Blog8 min read#flip card#css#3d

CSS Flip Card: 3D Rotate Animation With and Without JavaScript

Build a CSS flip card with smooth 3D rotation — pure CSS hover, JavaScript click toggle, and React component. Code examples for every approach included.

Abstract 3D geometric shapes with glowing neon colors on dark background

How the CSS 3D Flip Actually Works

A CSS flip card is a container with two faces — front and back — sitting on top of each other. When you rotate the container 180 degrees on the Y axis, the front disappears and the back appears. Simple idea. The implementation trips people up because there are three separate CSS concepts working together: transform-style: preserve-3d, perspective, and backface-visibility. Get one wrong and you get a flat flip, a broken stacking context, or a ghost image bleeding through.

transform-style: preserve-3d is the one that matters most. Without it, child elements collapse into the parent's 2D plane and your 3D rotation becomes a plain scale-X to zero then back. You set it on the card container — the element you're actually rotating — not on the faces themselves.

perspective controls how dramatic the depth looks. Set it on the *parent* of the rotating element, not on the rotating element itself. A value of 1000px gives you a natural, subtle depth. Drop to 400px and it gets exaggerated — cards will look like they're flying at you. In practice, 600px to 900px is the sweet spot for UI cards. Quick aside: you can also use the perspective() function directly inside a transform chain, which is handy for inline styles.

backface-visibility: hidden hides each face when it's pointing away from the viewer. Without it, you'd see a mirrored ghost of the back face through the front at certain rotation angles — weirdly common bug in tutorial code from 2018 and earlier. Set this on both .card-front and .card-back.

The back face starts pre-rotated at rotateY(180deg). So when you rotate the container to rotateY(180deg), the back face lands at 360deg — perfectly facing you — while the front hits 180deg and hides itself thanks to backface-visibility: hidden.

Pure CSS Flip Card (No JavaScript)

The cleanest approach — a hover-triggered flip with zero JavaScript. Works great for desktop product cards, team member bios, or any context where hover is a natural interaction. Here's the full implementation:

.flip-container {
  perspective: 800px;
  width: 300px;
  height: 200px;
}

.flip-card {
  width: 100%;
  height: 100%;
  position: relative;
  transform-style: preserve-3d;
  transition: transform 0.6s cubic-bezier(0.4, 0, 0.2, 1);
}

.flip-container:hover .flip-card {
  transform: rotateY(180deg);
}

.card-front,
.card-back {
  position: absolute;
  inset: 0;
  backface-visibility: hidden;
  border-radius: 12px;
  padding: 24px;
}

.card-back {
  transform: rotateY(180deg);
  background: #1a1a2e;
  color: #fff;
}

That cubic-bezier(0.4, 0, 0.2, 1) is Material Design's standard easing. You can swap it for ease-in-out and it'll look fine, but the cubic-bezier version has a slightly more physical feel — it eases in faster and settles softer. Worth the extra characters.

Honestly, the pure CSS hover flip is all you need for 80% of use cases. The main limitation is that it doesn't work on touch devices — hover states don't persist on mobile. If your card is desktop-only context (dashboards, marketing pages on desktop), ship the CSS-only version and move on.

One more thing — you can flip on the X axis too. Just swap rotateY for rotateX everywhere and add rotateX(180deg) to .card-back. Vertical flips read as page-turn animations and work well for flashcard or quiz interfaces.

JavaScript Click Toggle (Mobile-Friendly Approach)

For touch-first or accessible interfaces, you need a click toggle. The CSS stays almost identical — just remove the :hover rule and add a .is-flipped class that you toggle with JavaScript.

<div class="flip-container">
  <div class="flip-card" id="card">
    <div class="card-front">Front</div>
    <div class="card-back">Back</div>
  </div>
</div>

<script>
  const card = document.getElementById('card');
  card.parentElement.addEventListener('click', () => {
    card.classList.toggle('is-flipped');
  });
</script>
.flip-card.is-flipped {
  transform: rotateY(180deg);
}

That's it. Fourteen lines of JavaScript. The event listener goes on the container rather than the card itself so the entire clickable area (including padding) triggers the flip. If you put it on the inner .flip-card, you'll get hit-testing issues in some browsers where transform-style: preserve-3d elements have quirky pointer-event handling.

For accessibility, add role="button", tabindex="0", and a keydown handler for Enter/Space on the container. Also worth adding aria-pressed to communicate the flipped state to screen readers. Worth noting: prefers-reduced-motion users really don't want a 600ms 3D spin animation. Wrap the transition in a media query: ``css @media (prefers-reduced-motion: reduce) { .flip-card { transition: none; } } ``

React Flip Card Component

In React you'd manage the flipped state with useState and drive the class or inline style from there. Here's a reusable component that handles both hover and click modes:

import { useState } from 'react';

interface FlipCardProps {
  front: React.ReactNode;
  back: React.ReactNode;
  mode?: 'hover' | 'click';
  width?: number;
  height?: number;
}

export function FlipCard({
  front,
  back,
  mode = 'click',
  width = 300,
  height = 200,
}: FlipCardProps) {
  const [flipped, setFlipped] = useState(false);

  const containerProps =
    mode === 'click'
      ? { onClick: () => setFlipped((f) => !f) }
      : { onMouseEnter: () => setFlipped(true), onMouseLeave: () => setFlipped(false) };

  return (
    <div
      style={{ perspective: '800px', width, height, cursor: 'pointer' }}
      {...containerProps}
    >
      <div
        style={{
          width: '100%',
          height: '100%',
          position: 'relative',
          transformStyle: 'preserve-3d',
          transition: 'transform 0.55s cubic-bezier(0.4, 0, 0.2, 1)',
          transform: flipped ? 'rotateY(180deg)' : 'rotateY(0deg)',
        }}
      >
        <div style={{ position: 'absolute', inset: 0, backfaceVisibility: 'hidden' }}>
          {front}
        </div>
        <div
          style={{
            position: 'absolute',
            inset: 0,
            backfaceVisibility: 'hidden',
            transform: 'rotateY(180deg)',
          }}
        >
          {back}
        </div>
      </div>
    </div>
  );
}

Using inline styles here keeps the component self-contained — no CSS Module, no Tailwind class clash, no build config changes. You can drop it into any project. If you're on a Tailwind project you could translate the inline styles to utility classes, but you'd need to add [transform-style:preserve-3d] and [backface-visibility:hidden] as arbitrary values since those aren't in Tailwind's default utility set.

Look, if you're already using a component library you might not need to build this from scratch. Browse components, including ready-made animated cards in multiple styles — the glassmorphism components section has cards with the frosted-glass treatment that pairs beautifully with a 3D flip reveal.

For Framer Motion users, the component simplifies further — motion.div with rotateY in its animate prop handles the interpolation, and you get spring physics for free. That said, the native CSS transition version is lighter and performs better on lower-end Android devices where Framer Motion can introduce jank.

Styling the Flip Card Faces

The structural CSS gets you a working flip — the styling is where it becomes something people actually want to interact with. A few patterns that consistently work well in production.

Gradient + glassmorphism front face. A vivid gradient background on the card's parent or on the page itself, then a semi-transparent front face using background: rgba(255, 255, 255, 0.12) and backdrop-filter: blur(16px). The flip reveal shows a solid-color back with full information. If you want to build this look yourself, the glassmorphism generator outputs the exact CSS you need without any guesswork.

Dark-mode compatible solid cards. If glassmorphism isn't your thing, use CSS custom properties on both faces: ``css .card-front { background: var(--card-bg, #ffffff); color: var(--card-text, #111); border: 1px solid var(--card-border, #e5e7eb); } .card-back { background: var(--card-back-bg, #18181b); color: var(--card-back-text, #f4f4f5); } `` Your dark-mode media query or data-theme attribute flips the custom properties, and both card faces adapt automatically.

Shadow depth on hover. Add a box shadow that grows as the card rotates to reinforce the 3D illusion: ``css .flip-container:hover .flip-card { transform: rotateY(180deg); filter: drop-shadow(0 20px 40px rgba(0,0,0,0.3)); } ` For even more depth, combine with a subtle scale(1.02)` on hover — 2% is enough to feel responsive without being distracting. The box shadow generator is useful here if you want to visualise the layered shadow before committing to values.

Avoid putting heavy content — videos, iframes, canvas elements — inside flip card faces. The browser composites both faces even when one is hidden, so you're paying the render cost for both. For those use cases, conditionally render the back face content only after the first flip using a hasFlipped state flag in React.

Common Bugs and How to Fix Them

These are the errors you'll hit, roughly in order of how often they appear in Stack Overflow questions from the last few years.

The card flips but both faces are visible at the same time. You forgot backface-visibility: hidden on one or both faces, or you set it on the container instead of the faces. It must be on .card-front and .card-back individually.

The flip looks flat (no 3D effect, just a fade or instant swap). transform-style: preserve-3d is missing on the rotating element, or something is overriding it. Common culprits: overflow: hidden, will-change: opacity, and certain CSS filters (filter: blur()) create a new stacking context that flattens 3D children. If you need overflow clipping, apply it to each face independently, not to the .flip-card wrapper.

The back face content appears mirrored. You're seeing the front of the back face through the front card. This means backface-visibility isn't working, usually because you're in a flattened stacking context (see above). Also check that you haven't set transform-style: flat anywhere in the parent chain — some CSS resets do this.

Safari renders the flip differently. Safari has historically had quirks with transform-style: preserve-3d inside certain layouts. As of Safari 17 (2023), most of these are fixed. If you're supporting older Safari, add -webkit-transform-style: preserve-3d and -webkit-backface-visibility: hidden as well. Worth noting: Safari on iOS still requires -webkit- prefixes for backface-visibility on some versions.

The hover flip triggers on mobile after a tap, then stays stuck. This is the hover-persistence problem on touch devices. The solution is the JS click toggle approach, or use @media (hover: hover) to scope the :hover rule only to devices that support true hover: ``css @media (hover: hover) { .flip-container:hover .flip-card { transform: rotateY(180deg); } } ``

Performance Considerations

CSS 3D transforms are GPU-accelerated by default — the browser promotes transformed elements to their own compositor layer. That's good for animation smoothness. It can be bad for memory if you have dozens of flip cards on a page simultaneously, since each composited layer consumes VRAM.

Add will-change: transform to the .flip-card element to hint the browser to promote it *before* the animation starts. Without this hint, there's a small jank on the first flip in some browsers as the layer gets promoted mid-animation. Remove will-change on elements that aren't about to animate — keeping it on all the time wastes compositor resources.

.flip-card {
  will-change: transform; /* add this */
  transform-style: preserve-3d;
  transition: transform 0.55s cubic-bezier(0.4, 0, 0.2, 1);
}

In practice, for a page with fewer than 20 flip cards, none of this matters. You won't see a measurable difference. The will-change hint starts paying off in virtualized lists or card grids where dozens of items could flip in quick succession — think Pokémon card collection UIs or e-commerce product grids. If you're building that scale of interface, you might also look at the templates section to see how Empire UI structures animated component-heavy pages.

One last thing: transition: transform is correct. transition: all is tempting but will also transition properties you change on hover (like box-shadow, color), which can cause paint-heavy reflows alongside your GPU-composited transform animation. Keep the transition scoped to transform only.

FAQ

Do I need JavaScript to make a CSS flip card?

No — a hover-triggered flip works in pure CSS. You only need JavaScript if you want a click toggle (better for touch/mobile) or need to control the flip state from application logic.

Why isn't backface-visibility hiding my card faces?

Usually a stacking context issue. overflow: hidden, filter, or will-change: opacity on the flip-card wrapper flattens 3D children and breaks backface-visibility. Move those properties to the individual faces instead.

Can I flip on the X axis instead of Y axis?

Yes, swap every rotateY for rotateX and set .card-back { transform: rotateX(180deg); }. The result is a vertical page-turn style flip instead of horizontal.

How do I make the flip card accessible?

Add role="button", tabindex="0", and handle Enter/Space key events on the container. Add aria-pressed to communicate flipped state, and disable the transition with prefers-reduced-motion media query.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

3D Flip Card in CSS: Perspective, backface-visibility, Hover RevealCSS Scroll Snap: Precise Scrolling Sections Without JavaScriptClaymorphism Button: 3D Clay Press Animation in CSSClaymorphism Card Components: 3D Puffy Cards With Soft Shadows