CSS flip card: 3D flips on hover, focus and click

Four properties make a card turn over in 3D. Getting it to size itself, work without a mouse and keep its hidden side away from screen readers takes a little more, and all of it is below.

CSS recipe · By AurumCodeWorks · 7 min read · Updated 12 October 2026

Front Hover, or press Tab to reach the link
Back Turned by :focus-within More hover effects
Hover and focus flip — turns on the Y axis for a pointer, or when the link on its back face receives focus.
Front Tips over the X axis
Back Pre-rotated with rotateX(180deg) Flip-in entrance
Vertical flip — the same card turned top over bottom; both rotations use the same axis.
Question Which property hides the reverse of a face?
Answer backface-visibility: hidden MDN reference
Button-toggled flip — aria-pressed on a real button, and inert moved to whichever face is turned away.

When a flip card is the right pattern

A flip card puts two related pieces of content in the space of one and lets the reader swap between them. It suits a genuine reveal: the answer on a revision flashcard, the ingredients behind a recipe photo. It suits badly anything people need to compare or decide with, because they can only see one side at a time.

If the back face only adds a little emphasis, a lighter interaction is usually better. Our hover tilt card gives depth without hiding anything, and the flip-in entrance uses one rotation to bring content into view rather than swap it. When you do need two sides, this recipe makes the card size itself, lets everyone turn it over, and keeps the hidden side silent.

The four properties that make a 3D flip

A flip card is three layers: an outer card sets up the 3D view, an inner element rotates, and two faces sit inside it back to back.

.fc-card {
  --fc-flip: 0;
  perspective: 800px;
  width: min(100%, 13rem);
}
.fc-inner {
  display: grid;
  transform-style: preserve-3d;
  transform: rotateY(calc(var(--fc-flip) * 180deg));
  transition: transform 0.6s cubic-bezier(0.3, 0.7, 0.3, 1);
}
.fc-face {
  grid-area: 1 / 1;
  display: grid;
  align-content: center;
  gap: 0.4rem;
  min-height: 8rem;
  padding: 1rem;
  border-radius: 12px;
  text-align: center;
  backface-visibility: hidden;
}
.fc-back {
  transform: rotateY(180deg);
}

perspective on the card sets how far the viewer is from the screen. It gives depth to the element’s children, not to the element itself, so it belongs on the parent of whatever rotates. Smaller values exaggerate it: 300px makes the near edge balloon towards you, while 800px feels like a card on a table.

transform-style: preserve-3d on the inner element lets its children keep their own positions in 3D space instead of being painted flat into its plane. The property is not inherited, so it must sit on the element that rotates.

backface-visibility: hidden on both faces makes each face disappear while it points away from the viewer. Without it you would see the reverse of whichever face is turned away, mirrored like writing on glass.

rotateY(180deg) on the back face turns it away before anything happens. At rest the front points at you and the back is hidden; rotate the inner element by half a turn and the two swap.

The --fc-flip custom property is a 0-or-1 switch multiplied into the angle, so every trigger only has to set it to 1.

The perspective property or the perspective() function?

There are two ways to add depth, and they are easy to confuse. The perspective property on a parent creates one shared viewpoint, centred on that parent by default and movable with perspective-origin, for all its 3D children. The perspective() function goes inside an element’s own transform list, as in transform: perspective(800px) rotateY(180deg), and gives that one element its own viewpoint. It must come first in the list, because transform functions apply in order and a perspective added after the rotation has nothing to act on.

For a single card either works. For a row of cards, put the property on the row so every card shares one vanishing point, as real cards on a table would.

As MDN’s perspective reference notes, any value other than none creates a stacking context and makes the element the containing block for position: fixed descendants, so a fixed tooltip inside the card will be positioned against the card instead of the viewport.

Stack the faces with grid, not absolute positioning

Older tutorials give the card a fixed height and position both faces absolutely. That works until the back face has one more line than planned, then it overflows or gets cut off, and the height must be guessed again for every language and font size.

Grid solves this. The inner element becomes a grid with one implicit cell, and grid-area: 1 / 1 puts both faces in that cell. Items in the same cell overlap and the row grows to fit the tallest, so the card is always as tall as its longer side. The min-height only stops short cards looking cramped.

PropertyGoes onWhat breaks without it
perspectiveThe card: the parent of the element that rotatesNo depth. The card narrows to a line and widens again, like a horizontal squash, with no near or far edge.
transform-style: preserve-3dThe inner element that rotatesThe faces are flattened into one picture before it turns. You see the front face mirrored and the back face never appears.
backface-visibility: hiddenBoth facesThe turned-away face shows through as mirrored text, and which face wins depends on source order rather than direction.
transform: rotateY(180deg)The back face onlyAt rest the back face covers the front; after the flip both faces point away and the card goes blank.
display: grid and grid-area: 1 / 1The inner element and both facesThe faces sit one above the other, so the card is twice as tall, or you fall back on absolute positioning and a fixed height that clips longer text.

Horizontal and vertical flips

A horizontal flip turns the card around its vertical axis, like a page in a book, with rotateY(). A vertical flip tips it top over bottom with rotateX(), which suits wide, short cards such as a banner or a row in a table. The second demo only changes the axis.

.fc-vertical .fc-inner { transform: rotateX(calc(var(--fc-flip) * 180deg)); }
.fc-vertical .fc-back { transform: rotateX(180deg); }
.fc-vertical:is(:hover, :focus-within) { --fc-flip: 1; }

The detail that catches people out is that the back face’s pre-rotation must use the same axis as the flip. Leave the back face on rotateY(180deg) while the inner element turns with rotateX(), and the two half-turns combine into a rotation within the screen plane: the back face appears upside down. Change both together.

Safari, WebKit and the things that flatten a card

Much flip-card advice still tells you to add -webkit-backface-visibility for Safari. MDN’s compatibility data lists the unprefixed property in Safari and iOS Safari from 15.4, released in March 2022, and the backface-visibility reference marks it as widely available since then. Keep the prefixed line only if your audience includes iOS 15.3 or older; it is no longer the cure for a broken flip.

The failures you are more likely to meet today come from the specification, and they appear in every engine, not just WebKit. MDN’s transform-style reference lists grouping properties that force preserve-3d back to flat: overflow other than visible or clip, opacity below 1, any filter, clip-path, mask-image, mix-blend-mode, isolation: isolate and paint containment. Put one on the rotating element, say overflow: hidden to clip rounded corners, and the card goes flat. Apply them to the faces instead. Then test the turn in Safari, Chrome and Firefox, on a phone as well as a desktop.

Hover alone is not enough

The classic flip card turns on :hover, which leaves out two groups. Keyboard users never hover, so for them the back face does not exist. Touch screens have no hover either: mobile browsers fake it on tap and leave the card stuck flipped until the user taps elsewhere.

The first demo adds :focus-within to the hover rule, so the card also flips while anything inside it has focus. Because the link is on the back face, tabbing to it turns the card over and reveals it. That only helps when the back face contains something focusable, and screen reader users still hear both faces in turn, because backface-visibility only hides pixels. Use it only when reading both sides in order makes sense.

.fc-hover:is(:hover, :focus-within) { --fc-flip: 1; }

An accessible flip with a button and inert

For a card that people actually need to turn over, give them a control. A real <button> works with a mouse, a finger, a keyboard and a switch device, and aria-pressed tells assistive technology whether it is on. MDN’s aria-pressed reference warns against changing the label as the state changes, so it stays “Flip card” and a screen reader adds “pressed” or “not pressed”. If you would rather swap the text between “Show answer” and “Show question”, drop aria-pressed; aria-expanded also fits when the button reveals extra detail like a disclosure.

<button type="button" class="fc-btn" id="fc-quiz-btn" aria-controls="fc-quiz" aria-pressed="false">Flip card</button>
<div class="fc-card fc-toggle" id="fc-quiz">
  <div class="fc-inner">
    <div class="fc-face fc-front">
      <span class="fc-kicker">Question</span>
      <span class="fc-title">Which property hides the reverse of a face?</span>
    </div>
    <div class="fc-face fc-back" inert>
      <span class="fc-kicker">Answer</span>
      <span class="fc-title"><code>backface-visibility: hidden</code></span>
      <a class="fc-link" href="https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/backface-visibility" rel="noopener">MDN reference</a>
    </div>
  </div>
</div>
.fc-card.fc-is-flipped { --fc-flip: 1; }
.fc-btn {
  padding: 0.55em 1.2em;
  border: 1px solid #06b6d4;
  border-radius: 8px;
  background: transparent;
  color: #e2e8f0;
  font: inherit;
  font-weight: 600;
  cursor: pointer;
}
.fc-btn[aria-pressed="true"] { background: #06b6d4; color: #04111a; }
.fc-btn:focus-visible { outline: 2px solid #f8fafc; outline-offset: 3px; }
const fcBtn = document.getElementById('fc-quiz-btn');
const fcCard = document.getElementById('fc-quiz');
const [fcFront, fcBack] = fcCard.querySelectorAll('.fc-face');
fcBtn.addEventListener('click', () => {
  const flipped = fcBtn.getAttribute('aria-pressed') !== 'true';
  fcBtn.setAttribute('aria-pressed', String(flipped));
  fcCard.classList.toggle('fc-is-flipped', flipped);
  fcFront.inert = flipped;
  fcBack.inert = !flipped;
});

The inert attribute does the part CSS cannot. According to MDN’s inert reference, an inert subtree cannot be focused or clicked, is skipped by find-in-page, and is removed from the accessibility tree. It has been available across browsers since April 2023 (Chrome 102, Firefox 112, Safari 15.5). The back face starts inert, and the script moves the attribute to whichever face is turned away; nothing visual depends on it, so there is no need to wait for the transition.

The button sits before the card in the source, so after pressing it the next thing a screen reader reaches is the face that has just appeared. If you cannot use inert, toggle visibility: hidden on the turned-away face with a transition delay equal to the flip duration, so it vanishes only once it has rotated out of view; visibility also removes content from the tab order and the accessibility tree.

Reduced motion: cross-fade instead of turning

A large element spinning through 3D space is the kind of motion that can cause discomfort for people with vestibular disorders. The card still needs to change sides, so swap the rotation for a short cross-fade. Because every trigger sets the same --fc-flip switch, one block covers all three demos.

@media (prefers-reduced-motion: reduce) {
  .fc-card .fc-inner { transform: none; transition: none; }
  .fc-card .fc-face { transform: none; transition: opacity 0.3s ease; }
  .fc-card .fc-front { opacity: calc(1 - var(--fc-flip)); }
  .fc-card .fc-back { opacity: var(--fc-flip); }
}

With both rotations removed, the two faces lie flat in the same grid cell and opacity decides which one shows. The opacity is on the faces, not the rotating element, so it does not flatten anything, and the flip and the fade cost about the same: only transform or opacity change, both of which the compositor handles without repainting. Our guide to CSS animation performance explains why that matters.

Frequently asked questions

Why does my flip card show the front face backwards?

The faces are being flattened before the card turns. Check that transform-style: preserve-3d is on the element you rotate, not only on its parent, and that the same element has no overflow other than visible or clip, no opacity below 1 and no filter, because each of those forces the used value back to flat. Also check that both faces have backface-visibility: hidden.

Do I still need -webkit-backface-visibility for Safari?

Not for current browsers. MDN's compatibility data lists the unprefixed property in Safari and iOS Safari from version 15.4, released in March 2022. Add the prefixed line only if you still support iOS 15.3 or earlier; it does no harm, but it is no longer the fix for a broken flip.

How do I make a flip card work on touch screens and with a keyboard?

Do not rely on :hover. Put a real button next to the card with aria-pressed, and have it toggle a class that flips the card. If you keep a hover flip for mouse users, add :focus-within to the same rule so keyboard users flip the card when they tab to a link on it.

How do I stop screen readers reading the hidden side of a flip card?

Put the inert attribute on whichever face is turned away and move it when the card flips. backface-visibility only hides the face visually, so its text and links stay in the accessibility tree and the tab order. inert removes both and has been available across browsers since April 2023.