# Real Depth Needs preserve-3d

**MUST** · **ID:** `design-flip-card-depth` · **Category:** design
**Source:** [Web Platform](https://web.dev/)
**Interactive version:** https://ui-guides-agent-rules.netlify.app/principles/design-flip-card-depth

> MUST: For a flip card or any true 3D transform: put perspective on the container, transform-style: preserve-3d on the rotating element, and backface-visibility: hidden on each face. rotateY alone (default transform-style: flat) only mirror-flips a flat element — text reads backwards and there is no back face.

A flip card or any true 3D transform needs transform-style: preserve-3d and backface-visibility on the faces

The instinct for a flip card is to slap rotateY(180deg) on an element and call it 3D. It is not: with the default transform-style: flat, the element is a flat picture that turns edge-on and reappears mirrored — your text reads backwards and there is no back. Three properties make it real. perspective on the container gives the scene a vanishing point (see "Perspective on the Parent"). transform-style: preserve-3d on the rotating element keeps its two faces positioned in 3D rather than flattened onto one plane. backface-visibility: hidden on each face hides it while it points away from the viewer, so the front and a separately-authored back swap cleanly. Because the flip is user-initiated and brief a short transition is fine, but still gate longer or ambient 3D motion behind prefers-reduced-motion (see "Reduce Ambient 3D Motion").

## Rule snippet

```tsx
.scene{perspective:800px}
.card{transform-style:preserve-3d}
.face{backface-visibility:hidden}
```

## Bad — do not do this

`design-flip-card-depth-bad`

```tsx
import { useState } from 'react';
import { Layers } from 'lucide-react';

export function FlipCardDepthBad() {
  const [flipped, setFlipped] = useState(false);
  return (
    <div className="flex w-full max-w-sm flex-col items-center gap-6 rounded-xl bg-muted/40 py-8">
      <div className="h-44 w-64">
        {/* No perspective, no preserve-3d, no backface-visibility. */}
        <button
          onClick={() => setFlipped((f) => !f)}
          aria-pressed={flipped}
          className="flex h-full w-full flex-col items-center justify-center gap-2 rounded-xl border border-border bg-gradient-to-br from-card to-muted text-foreground shadow-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
          style={{ transition: 'transform 0.6s', transform: flipped ? 'rotateY(180deg)' : 'rotateY(0)' }}
        >
          <span className="flex h-10 w-10 items-center justify-center rounded-full bg-primary/15 text-primary">
            <Layers className="h-5 w-5" />
          </span>
          <span className="text-sm font-medium">Front</span>
          <span className="text-xs text-muted-foreground">tap to flip</span>
        </button>
      </div>
      <p className="text-center text-xs text-destructive">
        Without perspective or preserve-3d, rotateY just mirror-flips one flat element — the label turns
        backwards and there is no depth or real back face.
      </p>
    </div>
  );
}
```

## Good — do this

`design-flip-card-depth-good`

```tsx
import { useState } from 'react';
import { Layers, Check } from 'lucide-react';

export function FlipCardDepthGood() {
  const [flipped, setFlipped] = useState(false);
  return (
    <div className="flex w-full max-w-sm flex-col items-center gap-6 rounded-xl bg-muted/40 py-8">
      {/* perspective on the container gives the flip a real vanishing point. */}
      <div className="h-44 w-64" style={{ perspective: '900px' }}>
        <button
          onClick={() => setFlipped((f) => !f)}
          aria-pressed={flipped}
          className="relative h-full w-full rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
          style={{
            transformStyle: 'preserve-3d',
            transition: 'transform 0.6s cubic-bezier(0.4, 0, 0.2, 1)',
            transform: flipped ? 'rotateY(180deg)' : 'rotateY(0)',
          }}
        >
          <div
            className="absolute inset-0 flex flex-col items-center justify-center gap-2 rounded-xl border border-border bg-gradient-to-br from-card to-muted text-foreground shadow-xl"
            style={{ backfaceVisibility: 'hidden' }}
          >
            <span className="flex h-10 w-10 items-center justify-center rounded-full bg-primary/15 text-primary">
              <Layers className="h-5 w-5" />
            </span>
            <span className="text-sm font-medium">Front</span>
            <span className="text-xs text-muted-foreground">tap to flip</span>
          </div>
          <div
            className="absolute inset-0 flex flex-col items-center justify-center gap-2 rounded-xl border border-primary bg-primary text-primary-foreground shadow-xl"
            style={{ backfaceVisibility: 'hidden', transform: 'rotateY(180deg)' }}
          >
            <span className="flex h-10 w-10 items-center justify-center rounded-full bg-primary-foreground/20">
              <Check className="h-5 w-5" />
            </span>
            <span className="text-sm font-medium">Back — real depth</span>
            <span className="text-xs text-primary-foreground/80">a genuine second face</span>
          </div>
        </button>
      </div>
      <p className="text-center text-xs text-success">
        preserve-3d keeps both faces in 3D space; backface-visibility:hidden hides each one as it turns away,
        so the flip reveals a genuine back.
      </p>
    </div>
  );
}
```

## References

- [MDN — transform-style](https://developer.mozilla.org/en-US/docs/Web/CSS/transform-style)
- [MDN — backface-visibility](https://developer.mozilla.org/en-US/docs/Web/CSS/backface-visibility)
