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`.
Part of the series: Unlocking CSS 3D
- Part 1The Mental Model - Before You Write a Line of CSS 3D
- Part 2The Foundation - Mastering 2D Transforms
- Part 3Entering the Third Dimension - The Flipping Card
- Part 4Assembling a 3D Object - The CSS Cube
- Part 5The Final Polish - Interactivity, Lighting, and Performance
- Part 6The Real World - Practical 3D Components
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.
-
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 wrapperdiv. -
transform-style: preserve-3d(on the Child/Stage): This is the magic switch. By default, even if a parent hasperspective, it will flatten all of its children onto its own 2D plane. When we settransform-styletopreserve-3don 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:
- 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.
- 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: hiddenproperty 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:
- Initial State: The stage is at
rotateY(0deg). The front face is visible. The back face is atrotateY(180deg)and is facing away, so we can't see it. - Hover: We hover over the
groupcontainer. Thegroup-hoverclass on the stage kicks in, animating itstransformtorotateY(180deg). - Final State:
- The front face, which was at
0deg, is now effectively at180deg. Its back is facing us, andbackface-visibility: hiddenmakes it transparent. - The back face, which was already at
180deg, has the stage's180degrotation added to it, bringing its total rotation to360deg—which is the same as0deg. It is now perfectly facing us.
- The front face, which was at
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.
Try turning these off and triggering the flip to see why they are essential!
`backface-visibility` is applied to each face
A common mistake is to apply backface-visibility: hidden only to the parent flipping container. This property doesn't cascade down to its children.
For a flip effect to work correctly, you must apply this property individually to every element that acts as a face inside your 3D scene. This tells the browser to make each specific face transparent when its back is showing, rather than showing a weird, mirrored version of its content.
Example:
<!-- The flippable container (the stage) -->
<div class="[transform-style:preserve-3d]">
<!-- Front Face: visibility MUST be set here -->
<div class="face front [backface-visibility:hidden]">...</div>
<!-- Back Face: visibility MUST ALSO be set here -->
<div class="face back [backface-visibility:hidden]">...</div>
</div>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
- The 3D Scene Recipe: Your go-to structure is a parent with
[perspective:...]containing a child with[transform-style:preserve-3d]. - Stacking and Positioning: Use
position: absoluteto stack multiple faces in the same location before giving them their unique 3D transforms. - Hide What's Behind:
[backface-visibility:hidden]is non-negotiable for creating convincing two-sided objects. - Animate the Stage: Apply animations and transitions to the
preserve-3dcontainer (the "stage") to manipulate all the elements within it as a single unit.
End-of-Article Challenge
You have successfully built a card that flips around its vertical axis. Your understanding of rotateY is now solid.
Your Challenge: Create a new card that functions like a "trapdoor". It should be hinged at its top edge and flip downwards to reveal the back face.
Hints:
- To create a horizontal hinge, which axis must you rotate around?
You will need to replace every instance of
rotateYin your code with this new axis.The "feel" of the animation will change. Does
origin-centerstill feel right for a trapdoor, or would anothertransform-originfeel more physical?
Trapdoor Front
Hover to open.
Secret Revealed
You opened the trapdoor!
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.