TL;DR Use three layers. The parent sets perspective, the middle element rotates with transform-style: preserve-3d, and both faces stack with backface-visibility: hidden, the back pre-rotated 180deg. In Tailwind v4 that's perspective-distant, transform-3d, rotate-y-180 and backface-hidden. Add inert to the hidden face, keep overflow-hidden off the rotating element, and add motion-reduce:transition-none.
A 3D flip card in React needs three layers and no library. The outer element sets a perspective, the middle element rotates 180 degrees around the Y axis with transform-style: preserve-3d, and the two faces sit on top of each other with backface-visibility: hidden, the back one already turned 180 degrees. In Tailwind CSS v4 that's four utilities: perspective-distant, transform-3d, rotate-y-180 and backface-hidden. The rest of this post is the React part: who owns the flipped state, how to keep the hidden face away from keyboards and screen readers, and the four bugs that make a flip look flat or show both sides at once.
I make qodesign, a set of animated React + TypeScript + Tailwind components. Our Receipt Morph Checkout has a live card preview that flips over when you focus the CVC field, so this post comes out of building that. The code here is a simplified version written for this post, not the component's source. If you are building the card form around that preview, Credit Card Input in React: Spacing, Brand and Luhn covers the number field itself: grouping the digits, detecting the brand, keeping the caret in place and running the Luhn check.
Here's what each layer does and why it has to be a separate element.
The container gets perspective. This is the distance between the viewer and the card, and it's what makes the near edge look bigger while it turns. Tailwind v4 ships named values from perspective-dramatic (100px) to perspective-distant (1200px), plus perspective-[750px] for anything else. A small value looks dramatic and distorted, a large one looks calm. For a card a few hundred pixels wide, 800px to 1200px reads well.
The rotating element gets transform-3d (which is transform-style: preserve-3d) and the transition. Without preserve-3d its children are flattened into its plane, so both faces turn as one flat picture and the back never shows up.
The two faces are stacked with absolute inset-0 and both get backface-hidden, so each one disappears when it faces away from you. The back face also gets rotate-y-180, so it starts out facing away and comes around to face you when the parent turns.
The perspective has to sit on the parent of the rotating element, not on the rotating element itself. That's why it's three elements and not two.
Receipt Morph Checkout running live. Your card becomes the receipt: it splits, unrolls into paper and gets stamped PAID.
This component only renders. It takes flipped as a prop and has no state or effects, so it doesn't need 'use client' and can be imported from server or client components.
The size goes on the rotating element through className. Both faces are absolutely positioned, so without a height or an aspect ratio the card collapses to nothing.
The face that's turned away gets inert and aria-hidden. backface-visibility only hides it visually. Without inert, a link or button on the back would still take keyboard focus while you're looking at the front, and a screen reader would read both sides as one block of text. React 19 accepts inert as a boolean prop.
motion-reduce:transition-none turns the flip into an instant swap for people who set reduced motion in their OS. The state still changes, only the animation goes away. There's more on this in prefers-reduced-motion in React.
If you're on Tailwind v3, the 3D utilities don't exist yet. Arbitrary properties do the same job: [perspective:1200px], [transform-style:preserve-3d], [backface-visibility:hidden] and [transform:rotateY(180deg)].
The parent owns the state and the button that changes it. The button sits outside the card on purpose: if it lived on the front face, flipping would make it inert while it still had focus, and keyboard users would lose their place.
This is the pattern our checkout uses. The card preview is a picture of what you type, so the whole preview is aria-hidden and the real inputs stay the accessible part. Focusing the CVC input turns the card over, and blurring it turns it back.
aspect-[1.586] is the shape of a real bank card (85.6 by 53.98 mm). Notice that overflow-hidden and the rounded corners are on each face, not on the rotating element. That matters, and it's the second bug below.
One warning before you ship this: plain inputs like these are fine for a demo, but if raw card numbers reach your server, your site is in full PCI DSS scope. For real payments, let Stripe's hosted fields collect the card and drive the flip from their focus and blur events instead. We walked through that setup in a custom Stripe Elements card form in Next.js.
For pricing cards or team cards you might want a hover flip with no state at all. Put group on the container and let group-hover and group-focus-within turn the card:
group-focus-within means tabbing to the link on the back also turns the card, so keyboard users see what they focused. Touch screens have no real hover, though, so a phone user may never see the back. If the back holds anything important, use the click version instead, or show both sides stacked on small screens.
The back face shows up mirrored. You forgot rotate-y-180 on the back face, so it turns with the card and ends up reading backwards.
You can see both sides at once, or the card turns as one flat image. Either a face is missing backface-hidden, or the rotating element lost its 3D context. In the CSS Transforms spec, overflow other than visible, opacity below 1, filter and a few others are grouping properties: put one on an element with preserve-3d and it gets flattened. The usual culprit is overflow-hidden rounded-2xl on the rotating element. Move it to each face.
The flip has no depth. The perspective is missing or it's on the wrong element. It has to be on the parent of the element that rotates.
The card has zero height. Both faces are absolute, so give the rotating element a height or an aspect ratio.
Older Safari versions also needed the prefixed -webkit-backface-visibility. Our component's CSS sets both, so it doesn't depend on which version a visitor has.
No. A CSS transition on transform is all a flip needs, and the components above are plain React and Tailwind. A motion library helps if you want a spring that overshoots a little, or a flip you can drag with your finger. In our checkout component, the flip is a rotateY value that its animation engine writes straight to the DOM each frame, because the flip is one step in a longer timed sequence. For a single flip, the CSS version is less code. We compared the two approaches in Framer Motion vs CSS for micro-interactions.
If you're prompting Cursor, v0 or Lovable for a flip card, name the parts that get lost: perspective on the parent, preserve-3d on the rotating element, backface-hidden on both faces, the back face pre-rotated, inert on the hidden face and a reduced-motion fallback. With those in the prompt, the first result usually works.
The perspective is too small for the card's size. Try a larger value, like perspective-distant or perspective-[1600px] for wide cards. You can also move the vanishing point with perspective-origin utilities.
Yes. It has no hooks, so you can render it from a server component with a fixed flipped value. You only need a client component for the part that changes flipped, like the flash card or the payment form above.