Entering the Third Dimension - The Flipping Card

A hands-on tutorial to build a 3D flipping card with CSS. Learn the core concepts of `perspective`, `transform-style: preserve-3d`, and `backface-visibility`.

By Satish Kumar October 25, 2025

In our last article, we built a rock-solid foundation in 2D transforms. We learned to move, resize, and rotate elements in a performant way, and we discovered how transform-origin is the key to controlling the feel of an animation. We are now fluent in the language of the X and Y axes.

It is time to break the plane.

This article is your first practical leap into the third dimension. We will take the theories from our "Mental Model" article—perspective and the Z-axis—and apply them to a real component. By the end, you will have constructed the quintessential "Hello, World!" of CSS 3D: a card that realistically flips over on hover to reveal a hidden back face.

This single project will teach you the three most critical concepts for creating any 3D scene: establishing a3D space, managing the orientation of elements within it, and controlling their visibility.

The Two Ingredients for Any 3D Scene

Before a single element can appear to have depth, we must first build a "stage" for it to exist on. A 3D scene in CSS is not a single property, but a crucial parent-child relationship.

  1. perspective (on the Parent/Scene): This is our camera lens. As we learned in Article 0, this property defines the "distance" from the viewer's eye to the 2D plane of the screen, creating the illusion of depth. A lower value creates a more dramatic, distorted effect; a higher value is more subtle. We will apply this to a wrapper div.

  2. transform-style: preserve-3d (on the Child/Stage): This is the magic switch. By default, even if a parent has perspective, it will flatten all of its children onto its own 2D plane. When we set transform-style to preserve-3d on a child element, we're telling it: "You are now a 3D stage. Do not flatten your own children. Allow them to maintain their own positions and orientations within our shared 3D space."

Let's set up this fundamental structure in our page.tsx.

// app/page.tsx
export default function Home() {
  return (
    <main className='h-fit bg-background text-foreground flex justify-center items-center p-8'>
      {/* 1. The Scene: This element provides the "camera lens" */}
      <div className='[perspective:1000px]'>
        {/* 2. The Stage: This element is the 3D space our card will flip inside */}
        <div className='w-72 h-48 [transform-style:preserve-3d]'>
          {/* Our card faces will live here */}
        </div>
      </div>
    </main>
  );
}

This nested structure is the blueprint for all our 3D work. The outer div provides the perspective, and the inner div becomes the 3D container for our elements.

Building the Two Faces of the Card

A flipping card, naturally, needs a front and a back. We'll create two distinct divs and use absolute positioning to place them in the exact same spot within our "stage" container.

First, let's create a simple, reusable CardFace component.

// app/components/CardFace.tsx
import React from 'react';
import { cn } from '@/lib/utils';

interface CardFaceProps {
  children: React.ReactNode;
  className?: string;
}

export const CardFace = ({ children, className }: CardFaceProps) => {
  return (
    <div
      className={cn(
        'absolute w-full h-full rounded-lg border bg-card text-card-foreground p-6',
        'flex flex-col justify-center items-center text-center backdrop-blur-sm',
        className
      )}
    >
      {children}
    </div>
  );
};

Now, let's place two instances of this component into our stage.

import { CardFace } from './card-face';

export default function StageOne() {
  return (
    <main className='h-fit bg-background text-foreground flex justify-center items-center p-8'>
      <div className='[perspective:1000px]'>
        <div className='relative w-72 h-48 [transform-style:preserve-3d]'>
          {/* Front Face uses the default bg-card from the component */}
          <CardFace>
            <h2 className='text-xl font-bold'>Front Side</h2>
          </CardFace>

          {/* Back Face uses bg-muted for a distinguishable, theme-aware color */}
          <CardFace className='bg-muted'>
            <h2 className='text-xl font-bold text-muted-foreground'>
              Back Side
            </h2>
          </CardFace>
        </div>
      </div>
    </main>
  );
}

The backside card is currently overlapping front side card

Front Side

Back Side

At this point, you'll only see the back side, as it's the last element in the DOM and is rendered on top of the front. Our next task is to give them their correct 3D orientation.

Positioning in 3D and the backface-visibility Rule

This is where the illusion comes together. We need to do two things:

  1. Position the Back Face: We must pre-rotate the back face 180 degrees around the Y-axis so it starts off facing away from us.
  2. Hide the Backside: When an element is facing away from the viewer, its content is shown mirrored by default. This would ruin our effect. The backface-visibility: hidden property tells the browser to simply make an element transparent when its "back" is facing the viewer.

Let's apply these transforms and properties, and add the hover animation to the stage.

import { CardFace } from './card-face';

export default function StageTwo() {
  return (
    <main className='h-fit bg-background text-foreground flex justify-center items-center p-8'>
      {/* Add 'group' to the scene to control the hover state */}
      <div className='group [perspective:1000px]'>
        {/* Add transition and hover effect to the stage */}
        <div
          className='relative w-72 h-48 [transform-style:preserve-3d] 
                      transition-transform duration-700 ease-in-out
                      group-hover:[transform:rotateY(180deg)]'
        >
          {/* Front Face: Uses the default 'bg-card' from the component */}
          <CardFace className='[backface-visibility:hidden]'>
            <h2 className='text-xl font-bold'>Front Side</h2>
            <p className='mt-2 text-sm text-muted-foreground'>
              Hover over me to flip.
            </p>
          </CardFace>

          {/* Back Face: Uses 'bg-primary' for a clear, distinguishable color */}
          <CardFace className='bg-primary text-primary-foreground [backface-visibility:hidden] [transform:rotateY(180deg)]'>
            <h2 className='text-xl font-bold text-primary-foreground'>
              Back Side
            </h2>
            <p className='mt-2 text-sm text-primary-foreground/80'>
              Tada! You found me.
            </p>
          </CardFace>
        </div>
      </div>
    </main>
  );
}

Front Side

Hover over me to flip.

Back Side

Tada! You found me.

Let's trace the logic step-by-step:

  1. Initial State: The stage is at rotateY(0deg). The front face is visible. The back face is at rotateY(180deg) and is facing away, so we can't see it.
  2. Hover: We hover over the group container. The group-hover class on the stage kicks in, animating its transform to rotateY(180deg).
  3. Final State:
    • The front face, which was at 0deg, is now effectively at 180deg. Its back is facing us, and backface-visibility: hidden makes it transparent.
    • The back face, which was already at 180deg, has the stage's 180deg rotation added to it, bringing its total rotation to 360deg—which is the same as 0deg. It is now perfectly facing us.

The result is a seamless, physically believable flip animation.


Interactive Playground: The 3D Scene Inspector

Objective: To build a visceral understanding of why preserve-3d and backface-visibility are absolutely essential.

Front
Back

Try turning these off and triggering the flip to see why they are essential!

Code for the Playground Component:

'use client';

import React, { useState } from 'react';
import { CardFace } from './card-face'; // Assuming themed version
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Label } from '@/components/ui/label';

export const SceneInspectorPlayground = () => {
  const [isFlipped, setIsFlipped] = useState(false);
  const [usePreserve3d, setUsePreserve3d] = useState(true);
  const [useBackfaceVisibility, setUseBackfaceVisibility] = useState(true);

  return (
    // Main wrapper uses shadcn/ui variables for a themed look
    <div
      className={cn(
        'w-full rounded-lg border bg-card p-6 grid md:grid-cols-2 gap-8'
      )}
    >
      {/* Left side: The visual 3D scene */}
      <div className='[perspective:1000px] flex items-center justify-center'>
        <div
          className='relative w-56 h-36 transition-transform duration-700'
          style={{
            transform: isFlipped ? 'rotateY(180deg)' : 'rotateY(0deg)',
            transformStyle: usePreserve3d ? 'preserve-3d' : 'flat',
          }}
        >
          <CardFace
            className={cn(
              // Front face uses the default bg-card
              useBackfaceVisibility && '[backface-visibility:hidden]'
            )}
          >
            Front
          </CardFace>
          <CardFace
            className={cn(
              // Back face uses primary color for distinction
              'bg-primary text-primary-foreground',
              '[transform:rotateY(180deg)]',
              useBackfaceVisibility && '[backface-visibility:hidden]'
            )}
          >
            Back
          </CardFace>
        </div>
      </div>

      {/* Right side: The controls */}
      <div className='space-y-6 flex flex-col justify-center'>
        <Button onClick={() => setIsFlipped(!isFlipped)} className='w-full'>
          {isFlipped ? 'Flip Back' : 'Trigger Flip'}
        </Button>

        {/* Checkbox for transform-style */}
        <div className='flex items-center space-x-2'>
          <Checkbox
            id='preserve3d-checkbox'
            checked={usePreserve3d}
            onCheckedChange={() => setUsePreserve3d(!usePreserve3d)}
          />
          <Label
            htmlFor='preserve3d-checkbox'
            className='text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70'
          >
            <code className='font-mono'>transform-style: preserve-3d</code>
          </Label>
        </div>

        {/* Checkbox for backface-visibility */}
        <div className='flex items-center space-x-2'>
          <Checkbox
            id='backface-checkbox'
            checked={useBackfaceVisibility}
            onCheckedChange={() =>
              setUseBackfaceVisibility(!useBackfaceVisibility)
            }
          />
          <Label
            htmlFor='backface-checkbox'
            className='text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70'
          >
            <code className='font-mono'>backface-visibility: hidden</code>
          </Label>
        </div>

        <p className='text-xs text-muted-foreground pt-2'>
          Try turning these off and triggering the flip to see why they are
          essential!
        </p>
      </div>
    </div>
  );
};

Key Takeaways

  1. The 3D Scene Recipe: Your go-to structure is a parent with [perspective:...] containing a child with [transform-style:preserve-3d].
  2. Stacking and Positioning: Use position: absolute to stack multiple faces in the same location before giving them their unique 3D transforms.
  3. Hide What's Behind: [backface-visibility:hidden] is non-negotiable for creating convincing two-sided objects.
  4. Animate the Stage: Apply animations and transitions to the preserve-3d container (the "stage") to manipulate all the elements within it as a single unit.

With this skill mastered, you are no longer just positioning elements on a page; you are choreographing them in 3D space. In our next article, we will take this to its logical conclusion and construct a complete, volumetric object from six distinct faces.


Subscribe to my free Newsletter

Join my weekly newsletter to get the latest updates on design engineering, new projects, and articles I've written. No spam, unsubscribe at any time.