3D Flip Card Animation in React and Tailwind CSS (No Library)

6 min read

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.

On this page
  1. The three layers
  2. A reusable FlipCard component
  3. Flip on click: a flash card
  4. Flip on focus: a credit card that turns over for the CVC
  5. Flip on hover, without breaking phones
  6. Four bugs that break a flip
  7. Do you need Framer Motion for this?
  8. FAQ
  9. How do I flip the card vertically instead?
  10. Why does my card look stretched or warped while it turns?
  11. Can FlipCard be a Server Component?
  12. Where can I see the card flip in a full checkout?

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.

The three layers

Here's what each layer does and why it has to be a separate element.

  1. 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.
  2. 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.
  3. 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.

A reusable FlipCard component

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.

tsx
import type { ReactNode } from 'react';

type FlipCardProps = {
  flipped: boolean;
  front: ReactNode;
  back: ReactNode;
  className?: string;
};

export function FlipCard({ flipped, front, back, className = 'h-48 w-80' }: FlipCardProps) {
  return (
    <div className="perspective-distant">
      <div
        className={`relative transform-3d transition-transform duration-500 ease-out motion-reduce:transition-none ${
          flipped ? 'rotate-y-180' : ''
        } ${className}`}
      >
        <div className="absolute inset-0 backface-hidden" aria-hidden={flipped} inert={flipped}>
          {front}
        </div>
        <div className="absolute inset-0 rotate-y-180 backface-hidden" aria-hidden={!flipped} inert={!flipped}>
          {back}
        </div>
      </div>
    </div>
  );
}

A few choices in there are on purpose:

  • 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)].

Flip on click: a flash card

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.

tsx
'use client';

import { useState } from 'react';
import { FlipCard } from './FlipCard';

export function FlashCard({ question, answer }: { question: string; answer: string }) {
  const [flipped, setFlipped] = useState(false);

  return (
    <div className="grid justify-items-start gap-3">
      <FlipCard
        flipped={flipped}
        className="h-44 w-72"
        front={<div className="grid h-full place-items-center rounded-2xl bg-zinc-900 p-6 text-white">{question}</div>}
        back={<div className="grid h-full place-items-center rounded-2xl bg-lime-300 p-6 text-zinc-900">{answer}</div>}
      />
      <button type="button" onClick={() => setFlipped((f) => !f)} className="text-sm underline">
        {flipped ? 'Show question' : 'Show answer'}
      </button>
    </div>
  );
}

The button's label says what will happen next, so it doesn't need aria-pressed as well.

Flip on focus: a credit card that turns over for the CVC

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.

tsx
'use client';

import { useState } from 'react';
import { FlipCard } from './FlipCard';

export function PaymentPreview() {
  const [number, setNumber] = useState('');
  const [cvc, setCvc] = useState('');
  const [cvcFocused, setCvcFocused] = useState(false);

  return (
    <div className="grid gap-4">
      <div aria-hidden="true">
        <FlipCard
          flipped={cvcFocused}
          className="aspect-[1.586] w-80"
          front={
            <div className="h-full overflow-hidden rounded-2xl bg-zinc-900 p-5 font-mono text-white">
              <p className="mt-16 tracking-widest tabular-nums">{number || '•••• •••• •••• ••••'}</p>
            </div>
          }
          back={
            <div className="h-full overflow-hidden rounded-2xl bg-zinc-800 pt-6">
              <div className="h-10 bg-black" />
              <p className="mr-5 mt-4 text-right font-mono text-white">{cvc || '•••'}</p>
            </div>
          }
        />
      </div>
      <label className="grid gap-1 text-sm">
        Card number
        <input inputMode="numeric" autoComplete="cc-number" value={number} onChange={(e) => setNumber(e.target.value)} />
      </label>
      <label className="grid gap-1 text-sm">
        CVC
        <input
          inputMode="numeric"
          autoComplete="cc-csc"
          maxLength={4}
          value={cvc}
          onChange={(e) => setCvc(e.target.value)}
          onFocus={() => setCvcFocused(true)}
          onBlur={() => setCvcFocused(false)}
        />
      </label>
    </div>
  );
}

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.

Flip on hover, without breaking phones

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:

tsx
export function HoverFlipCard() {
  return (
    <div className="group h-48 w-80 perspective-distant">
      <div className="relative h-full transform-3d transition-transform duration-500 group-hover:rotate-y-180 group-focus-within:rotate-y-180 motion-reduce:transition-none">
        <div className="absolute inset-0 rounded-2xl bg-zinc-900 p-6 text-white backface-hidden">Pro plan</div>
        <div className="absolute inset-0 rotate-y-180 rounded-2xl bg-zinc-100 p-6 backface-hidden">
          <p>Everything in Free, plus team seats.</p>
          <a href="/pricing" className="underline">See pricing</a>
        </div>
      </div>
    </div>
  );
}

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.

Four bugs that break a flip

  1. 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.
  2. 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.
  3. 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.
  4. 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.

Do you need Framer Motion for this?

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.

FAQ

How do I flip the card vertically instead?

Swap the Y axis for X: use rotate-x-180 on the rotating element when it's flipped and on the back face. Everything else stays the same.

Why does my card look stretched or warped while it turns?

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.

Can FlipCard be a Server Component?

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.

Where can I see the card flip in a full checkout?

The Receipt Morph Checkout flips its live card for the CVC, then turns the card into a printed receipt when the payment goes through. There's a walkthrough of the checkout receipt animation in React, and our animated credit card form uses a flip too.