3D Transforms, perspective and backface-visibility Costs

Part of Hardware-Accelerated Properties in Core CSS Animation Fundamentals.

The problem

A pricing page flips cards over on hover to show details. A gallery tilts images towards the pointer in 3D. A product viewer rotates a CSS cube. All three look smooth on a desktop and stutter on a phone, and the Layers panel shows far more layers than elements. Meanwhile, an old stylesheet sprinkles transform: translateZ(0) on dozens of elements “for GPU acceleration”.

3D transforms are compositor-friendly, but they are not free. They change how elements are grouped into layers, how those layers are sorted, and how much texture memory the page holds.

Root cause analysis: 3D decides layering

Any 3D transform promotes. A non-identity 3D transform — translateZ(), rotateY(), perspective() in the transform list, translate3d() — gives the element a compositing reason. That is why translateZ(0) was used as a promotion hack: it is an identity transform that still counts as 3D. Every hacked element costs a texture sized to its area times the square of the device pixel ratio, whether it animates or not, the problem described in layer promotion strategy.

transform-style: preserve-3d creates a shared 3D context. Children keep their own 3D positions instead of being flattened into the parent’s plane. The compositor must then keep each child as a separate layer, sort those layers by depth each frame, and — if planes intersect — split them to draw correctly. A cube made of six faces in preserve-3d is six layers plus sorting; a card with front and back faces is two layers plus sorting.

perspective on the parent versus perspective() in the transform. The perspective property on a container sets one vanishing point shared by all children, and is cheap to reason about. A perspective() function inside each child’s transform gives each child its own vanishing point, which looks different and makes each transform list longer and harder to interpolate.

backface-visibility: hidden hides an element when its back faces the viewer. It is essential for flip cards — without it the front face’s mirror image shows through — and it is itself another compositing reason in some engines.

Layers created by a flip card in preserve-3dTwo faces become two sorted layers; hacks on children add more.Layers created by a flip card in preserve-3dBack face (rotateY(180deg), backface hidden)Own layer, depth-sortedFront face (backface hidden)Own layer, depth-sortedCard (preserve-3d, animates rotateY)3D rendering contextPage layerFlattened 2D content
Two faces become two sorted layers; hacks on children add more.

Step-by-step resolution

Keeping 3D effects leanMost savings come from removing 3D where it adds nothing.Keeping 3D effects lean1Search for translateZ(0), translate3d(0,0,0) and backface hacks on elements that do not animatein 3D.Candidates for deletion2Delete them and re-check the Layers panel.Idle layers disappear3For tilt effects, use rotateX and rotateY on one element with perspective on its parent.One layer, no preserve-3d4Use preserve-3d only for true multi-face objects like flip cards.Sorting limited to where it is needed5Promote the flipping card only while it flips.Faces release textures at rest6Provide a cross-fade for reduced motion.
Most savings come from removing 3D where it adds nothing.

Production code pattern

/* Flip card: two faces genuinely need a shared 3D space. */
.flip {
  perspective: 900px;                 /* one vanishing point for the card */
}
.flip__card {
  display: grid;
  transform-style: preserve-3d;
  transition: rotate 520ms cubic-bezier(0.3, 0.7, 0.2, 1);
}
.flip__face {
  grid-area: 1 / 1;                   /* stack both faces in the same cell */
  backface-visibility: hidden;
}
.flip__face--back { rotate: y 180deg; }
.flip:is(:hover, :focus-within) .flip__card { rotate: y 180deg; }

/* Tilt: no preserve-3d needed, one element, one layer while moving. */
.tilt {
  perspective: 700px;
}
.tilt__image {
  transition: rotate 200ms ease-out;
  rotate: var(--tilt-axis, 0 1 0) var(--tilt, 0deg);
}

@media (prefers-reduced-motion: reduce) {
  .flip__card { transition: none; transform-style: flat; }
  .flip__face { backface-visibility: visible; transition: opacity 150ms linear; }
  .flip__face--back { rotate: none; opacity: 0; }
  .flip:is(:hover, :focus-within) .flip__card { rotate: none; }
  .flip:is(:hover, :focus-within) .flip__face--back { opacity: 1; }
  .flip:is(:hover, :focus-within) .flip__face--front { opacity: 0; }
  .tilt__image { rotate: none; transition: none; }
}

Rendering Impact: composite, with extra layers. The flip rotates a 3D context on the compositor, which sorts two face layers per frame; the tilt is a single promoted layer. Neither touches layout or paint while animating.

The reduced-motion branch is more than transition: none. Without rotation, both faces would sit on top of each other with the back face hidden only by its rotation, so the branch flattens the card and swaps faces with opacity instead.

Composited layers on a 12-card pricing gridCounted in the Layers panel at rest; the hack version holds layers even when nothing moves.Composited layers on a 12-card pricing gridtranslateZ(0) on every card and face36 layerspreserve-3d always on, no hacks24 layerspreserve-3d only while flipping2 layers
Counted in the Layers panel at rest; the hack version holds layers even when nothing moves.

Verification checklist

Constraints and trade-offs

  • preserve-3d is flattened by overflow other than visible, filter, opacity below 1 and several other properties on the same element.
  • Depth sorting of intersecting planes is expensive and can produce z-fighting artefacts.
  • backface-visibility: hidden may itself cause promotion; apply it only to faces that flip.
  • Text on rotated faces is rasterised into textures and can look soft mid-rotation.
  • Hover-only flips hide content from touch and keyboard users unless focus and tap states are also handled.

Frequently asked questions

Does translateZ(0) make animations faster?

It forces a layer, which can remove a paint cost for an element that animates transform or opacity, but will-change does that more explicitly and can be removed afterwards. On elements that do not animate, it only costs memory.

When do I need transform-style: preserve-3d?

Only when children must exist in a shared 3D space, such as the two faces of a flip card or the sides of a cube. A single element tilting in 3D does not need it.

Why does my flip card show a mirrored front face?

The faces are missing backface-visibility: hidden, or preserve-3d is being flattened by overflow, opacity or filter on the card element.