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.
| Property | Goes on | What breaks without it |
|---|---|---|
perspective | The card: the parent of the element that rotates | No depth. The card narrows to a line and widens again, like a horizontal squash, with no near or far edge. |
transform-style: preserve-3d | The inner element that rotates | The faces are flattened into one picture before it turns. You see the front face mirrored and the back face never appears. |
backface-visibility: hidden | Both faces | The 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 only | At 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 / 1 | The inner element and both faces | The 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.