15 CSS 3D Tilt Hover Cards12 / 15
Flip-to-Back 3D Tilt Card
Tilt and flip in one transform: the card follows the cursor like any tilt card, but click (or press Enter/Space) and it swings a full 180° to reveal contact details on its back — and keeps tilting while flipped. The trick is composing rotateY(flip + tilt) as a single calc(), never two competing transforms.
Published
The code
<section class="thc-12" aria-label="Flip-to-back tilt card demo">
<div class="thc-12__scene">
<div class="thc-12__carrier">
<article class="thc-12__face thc-12__face--front" aria-hidden="false">
<p class="thc-12__kicker">FREELANCE</p>
<h3 class="thc-12__name">Jonah Reyes</h3>
<p class="thc-12__role">Motion & Brand Designer</p>
<button class="thc-12__flipbtn" type="button" aria-pressed="false">Contact ↻</button>
</article>
<article class="thc-12__face thc-12__face--back" aria-hidden="true">
<p class="thc-12__kicker">SAY HELLO</p>
<ul class="thc-12__contacts">
<li>jonah@studioreyes.co</li>
<li>+1 (415) 555-0132</li>
<li>@jonahmakes</li>
</ul>
<button class="thc-12__flipbtn thc-12__flipbtn--back" type="button" aria-pressed="true">Back ↺</button>
</article>
</div>
</div>
</section><section class="thc-12" aria-label="Flip-to-back tilt card demo">
<div class="thc-12__scene">
<div class="thc-12__carrier">
<article class="thc-12__face thc-12__face--front" aria-hidden="false">
<p class="thc-12__kicker">FREELANCE</p>
<h3 class="thc-12__name">Jonah Reyes</h3>
<p class="thc-12__role">Motion & Brand Designer</p>
<button class="thc-12__flipbtn" type="button" aria-pressed="false">Contact ↻</button>
</article>
<article class="thc-12__face thc-12__face--back" aria-hidden="true">
<p class="thc-12__kicker">SAY HELLO</p>
<ul class="thc-12__contacts">
<li>jonah@studioreyes.co</li>
<li>+1 (415) 555-0132</li>
<li>@jonahmakes</li>
</ul>
<button class="thc-12__flipbtn thc-12__flipbtn--back" type="button" aria-pressed="true">Back ↺</button>
</article>
</div>
</div>
</section>.thc-12,
.thc-12 *,
.thc-12 *::before,
.thc-12 *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
.thc-12 {
--acc: oklch(0.75 0.14 80);
font-family: 'Segoe UI',system-ui,sans-serif;
width: 100%;
min-height: 100vh;
display: grid;
place-items: center;
padding: 48px 24px;
background: radial-gradient(80% 90% at 70% 20%,oklch(0.25 0.05 80 / .7),transparent 60%),oklch(0.15 0.015 270);
}
.thc-12 .thc-12__scene {
perspective: 1100px;
width: min(300px,100%);
}
.thc-12 .thc-12__carrier {
position: relative;
height: 200px;
transform-style: preserve-3d;
transform: rotateX(var(--rx,0deg)) rotateY(calc(var(--flip,0deg) + var(--ry,0deg)));
transition: transform .7s cubic-bezier(.3,.8,.3,1.1);
}
.thc-12 .thc-12__carrier.is-live {
transition: transform .09s linear;
}
.thc-12 .thc-12__carrier.is-flipping {
transition: transform .7s cubic-bezier(.3,.8,.3,1.1);
}
.thc-12 .thc-12__face {
position: absolute;
inset: 0;
border-radius: 18px;
padding: 26px;
backface-visibility: hidden;
-webkit-backface-visibility: hidden;
background: linear-gradient(160deg,oklch(0.23 0.03 80),oklch(0.17 0.02 270));
border: 1px solid color-mix(in oklab,var(--acc) 35%,transparent);
box-shadow: 0 26px 54px -26px rgba(0,0,0,.8);
display: flex;
flex-direction: column;
}
.thc-12 .thc-12__face--back {
transform: rotateY(180deg);
}
.thc-12 .thc-12__kicker {
font: 700 10px ui-monospace,monospace;
letter-spacing: .2em;
color: var(--acc);
}
.thc-12 .thc-12__name {
margin-top: 12px;
font-size: 24px;
font-weight: 800;
color: #fff;
}
.thc-12 .thc-12__role {
margin-top: 4px;
font-size: 13px;
color: rgba(255,255,255,.6);
}
.thc-12 .thc-12__contacts {
list-style: none;
margin-top: 14px;
display: flex;
flex-direction: column;
gap: 8px;
font-size: 13.5px;
color: rgba(255,255,255,.85);
}
.thc-12 .thc-12__flipbtn {
margin-top: auto;
align-self: flex-start;
font: 600 12px 'Segoe UI',sans-serif;
color: oklch(0.15 0.02 270);
background: var(--acc);
border: none;
border-radius: 999px;
padding: 9px 16px;
cursor: pointer;
transition: transform .2s ease;
}
.thc-12 .thc-12__flipbtn:hover {
transform: scale(1.05);
}
.thc-12 .thc-12__flipbtn:focus-visible {
outline: 3px solid #fff;
outline-offset: 3px;
}
@media (prefers-reduced-motion: reduce) {
.thc-12 .thc-12__carrier {
transition: none;
}
}.thc-12,
.thc-12 *,
.thc-12 *::before,
.thc-12 *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
.thc-12 {
--acc: oklch(0.75 0.14 80);
font-family: 'Segoe UI',system-ui,sans-serif;
width: 100%;
min-height: 100vh;
display: grid;
place-items: center;
padding: 48px 24px;
background: radial-gradient(80% 90% at 70% 20%,oklch(0.25 0.05 80 / .7),transparent 60%),oklch(0.15 0.015 270);
}
.thc-12 .thc-12__scene {
perspective: 1100px;
width: min(300px,100%);
}
.thc-12 .thc-12__carrier {
position: relative;
height: 200px;
transform-style: preserve-3d;
transform: rotateX(var(--rx,0deg)) rotateY(calc(var(--flip,0deg) + var(--ry,0deg)));
transition: transform .7s cubic-bezier(.3,.8,.3,1.1);
}
.thc-12 .thc-12__carrier.is-live {
transition: transform .09s linear;
}
.thc-12 .thc-12__carrier.is-flipping {
transition: transform .7s cubic-bezier(.3,.8,.3,1.1);
}
.thc-12 .thc-12__face {
position: absolute;
inset: 0;
border-radius: 18px;
padding: 26px;
backface-visibility: hidden;
-webkit-backface-visibility: hidden;
background: linear-gradient(160deg,oklch(0.23 0.03 80),oklch(0.17 0.02 270));
border: 1px solid color-mix(in oklab,var(--acc) 35%,transparent);
box-shadow: 0 26px 54px -26px rgba(0,0,0,.8);
display: flex;
flex-direction: column;
}
.thc-12 .thc-12__face--back {
transform: rotateY(180deg);
}
.thc-12 .thc-12__kicker {
font: 700 10px ui-monospace,monospace;
letter-spacing: .2em;
color: var(--acc);
}
.thc-12 .thc-12__name {
margin-top: 12px;
font-size: 24px;
font-weight: 800;
color: #fff;
}
.thc-12 .thc-12__role {
margin-top: 4px;
font-size: 13px;
color: rgba(255,255,255,.6);
}
.thc-12 .thc-12__contacts {
list-style: none;
margin-top: 14px;
display: flex;
flex-direction: column;
gap: 8px;
font-size: 13.5px;
color: rgba(255,255,255,.85);
}
.thc-12 .thc-12__flipbtn {
margin-top: auto;
align-self: flex-start;
font: 600 12px 'Segoe UI',sans-serif;
color: oklch(0.15 0.02 270);
background: var(--acc);
border: none;
border-radius: 999px;
padding: 9px 16px;
cursor: pointer;
transition: transform .2s ease;
}
.thc-12 .thc-12__flipbtn:hover {
transform: scale(1.05);
}
.thc-12 .thc-12__flipbtn:focus-visible {
outline: 3px solid #fff;
outline-offset: 3px;
}
@media (prefers-reduced-motion: reduce) {
.thc-12 .thc-12__carrier {
transition: none;
}
}(() => {
const scene = document.querySelector('.thc-12 .thc-12__scene');
if (!scene) return;
const carrier = scene.querySelector('.thc-12__carrier');
const faces = carrier.querySelectorAll('.thc-12__face');
const btns = carrier.querySelectorAll('.thc-12__flipbtn');
let flipped = false;
const setFlip = (f) => {
flipped = f;
carrier.style.setProperty('--flip', f ? '180deg' : '0deg');
faces[0].setAttribute('aria-hidden', String(f));
faces[1].setAttribute('aria-hidden', String(!f));
btns.forEach((b) => b.setAttribute('aria-pressed', String(f)));
carrier.classList.add('is-flipping'); // force the slow bezier for the swing
carrier.classList.remove('is-live');
setTimeout(() => carrier.classList.remove('is-flipping'), 700);
};
btns.forEach((b) => b.addEventListener('click', () => setFlip(!flipped)));
if (!matchMedia('(hover:hover) and (pointer:fine)').matches) return;
if (matchMedia('(prefers-reduced-motion: reduce)').matches) return;
const MAX = 10;
scene.addEventListener('pointermove', (e) => {
if (carrier.classList.contains('is-flipping')) return; // let the swing finish clean
const r = scene.getBoundingClientRect();
const px = (e.clientX - r.left) / r.width, py = (e.clientY - r.top) / r.height;
const dir = flipped ? -1 : 1; // pointer-left is back-face-right
carrier.style.setProperty('--rx', ((0.5 - py) * MAX).toFixed(2) + 'deg');
carrier.style.setProperty('--ry', (dir * (px - 0.5) * MAX).toFixed(2) + 'deg');
carrier.classList.add('is-live');
});
scene.addEventListener('pointerleave', () => {
carrier.classList.remove('is-live');
carrier.style.setProperty('--rx', '0deg');
carrier.style.setProperty('--ry', '0deg');
});
})();(() => {
const scene = document.querySelector('.thc-12 .thc-12__scene');
if (!scene) return;
const carrier = scene.querySelector('.thc-12__carrier');
const faces = carrier.querySelectorAll('.thc-12__face');
const btns = carrier.querySelectorAll('.thc-12__flipbtn');
let flipped = false;
const setFlip = (f) => {
flipped = f;
carrier.style.setProperty('--flip', f ? '180deg' : '0deg');
faces[0].setAttribute('aria-hidden', String(f));
faces[1].setAttribute('aria-hidden', String(!f));
btns.forEach((b) => b.setAttribute('aria-pressed', String(f)));
carrier.classList.add('is-flipping'); // force the slow bezier for the swing
carrier.classList.remove('is-live');
setTimeout(() => carrier.classList.remove('is-flipping'), 700);
};
btns.forEach((b) => b.addEventListener('click', () => setFlip(!flipped)));
if (!matchMedia('(hover:hover) and (pointer:fine)').matches) return;
if (matchMedia('(prefers-reduced-motion: reduce)').matches) return;
const MAX = 10;
scene.addEventListener('pointermove', (e) => {
if (carrier.classList.contains('is-flipping')) return; // let the swing finish clean
const r = scene.getBoundingClientRect();
const px = (e.clientX - r.left) / r.width, py = (e.clientY - r.top) / r.height;
const dir = flipped ? -1 : 1; // pointer-left is back-face-right
carrier.style.setProperty('--rx', ((0.5 - py) * MAX).toFixed(2) + 'deg');
carrier.style.setProperty('--ry', (dir * (px - 0.5) * MAX).toFixed(2) + 'deg');
carrier.classList.add('is-live');
});
scene.addEventListener('pointerleave', () => {
carrier.classList.remove('is-live');
carrier.style.setProperty('--rx', '0deg');
carrier.style.setProperty('--ry', '0deg');
});
})();Here's a working CSS 3D Tilt Hover Card from CodeFronts. Use it as-is or adapt to your framework. All classes are scoped under a unique prefix so the code won't collide with your existing styles. MIT licensed.
Demo: Flip-to-Back 3D Tilt Card
Source: https://codefronts.com/components/css-3d-tilt-hover-cards/flip-to-back-3d-tilt-card/
Tilt and flip in one transform: the card follows the cursor like any tilt card, but click (or press Enter/Space) and it swings a full 180° to reveal contact details on its back — and keeps tilting while flipped. The trick is composing rotateY(flip + tilt) as a single calc(), never two competing transforms.
## HTML
```html
<section class="thc-12" aria-label="Flip-to-back tilt card demo">
<div class="thc-12__scene">
<div class="thc-12__carrier">
<article class="thc-12__face thc-12__face--front" aria-hidden="false">
<p class="thc-12__kicker">FREELANCE</p>
<h3 class="thc-12__name">Jonah Reyes</h3>
<p class="thc-12__role">Motion & Brand Designer</p>
<button class="thc-12__flipbtn" type="button" aria-pressed="false">Contact ↻</button>
</article>
<article class="thc-12__face thc-12__face--back" aria-hidden="true">
<p class="thc-12__kicker">SAY HELLO</p>
<ul class="thc-12__contacts">
<li>jonah@studioreyes.co</li>
<li>+1 (415) 555-0132</li>
<li>@jonahmakes</li>
</ul>
<button class="thc-12__flipbtn thc-12__flipbtn--back" type="button" aria-pressed="true">Back ↺</button>
</article>
</div>
</div>
</section>
```
## CSS
```css
.thc-12,
.thc-12 *,
.thc-12 *::before,
.thc-12 *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
.thc-12 {
--acc: oklch(0.75 0.14 80);
font-family: 'Segoe UI',system-ui,sans-serif;
width: 100%;
min-height: 100vh;
display: grid;
place-items: center;
padding: 48px 24px;
background: radial-gradient(80% 90% at 70% 20%,oklch(0.25 0.05 80 / .7),transparent 60%),oklch(0.15 0.015 270);
}
.thc-12 .thc-12__scene {
perspective: 1100px;
width: min(300px,100%);
}
.thc-12 .thc-12__carrier {
position: relative;
height: 200px;
transform-style: preserve-3d;
transform: rotateX(var(--rx,0deg)) rotateY(calc(var(--flip,0deg) + var(--ry,0deg)));
transition: transform .7s cubic-bezier(.3,.8,.3,1.1);
}
.thc-12 .thc-12__carrier.is-live {
transition: transform .09s linear;
}
.thc-12 .thc-12__carrier.is-flipping {
transition: transform .7s cubic-bezier(.3,.8,.3,1.1);
}
.thc-12 .thc-12__face {
position: absolute;
inset: 0;
border-radius: 18px;
padding: 26px;
backface-visibility: hidden;
-webkit-backface-visibility: hidden;
background: linear-gradient(160deg,oklch(0.23 0.03 80),oklch(0.17 0.02 270));
border: 1px solid color-mix(in oklab,var(--acc) 35%,transparent);
box-shadow: 0 26px 54px -26px rgba(0,0,0,.8);
display: flex;
flex-direction: column;
}
.thc-12 .thc-12__face--back {
transform: rotateY(180deg);
}
.thc-12 .thc-12__kicker {
font: 700 10px ui-monospace,monospace;
letter-spacing: .2em;
color: var(--acc);
}
.thc-12 .thc-12__name {
margin-top: 12px;
font-size: 24px;
font-weight: 800;
color: #fff;
}
.thc-12 .thc-12__role {
margin-top: 4px;
font-size: 13px;
color: rgba(255,255,255,.6);
}
.thc-12 .thc-12__contacts {
list-style: none;
margin-top: 14px;
display: flex;
flex-direction: column;
gap: 8px;
font-size: 13.5px;
color: rgba(255,255,255,.85);
}
.thc-12 .thc-12__flipbtn {
margin-top: auto;
align-self: flex-start;
font: 600 12px 'Segoe UI',sans-serif;
color: oklch(0.15 0.02 270);
background: var(--acc);
border: none;
border-radius: 999px;
padding: 9px 16px;
cursor: pointer;
transition: transform .2s ease;
}
.thc-12 .thc-12__flipbtn:hover {
transform: scale(1.05);
}
.thc-12 .thc-12__flipbtn:focus-visible {
outline: 3px solid #fff;
outline-offset: 3px;
}
@media (prefers-reduced-motion: reduce) {
.thc-12 .thc-12__carrier {
transition: none;
}
}
```
## JavaScript
```js
(() => {
const scene = document.querySelector('.thc-12 .thc-12__scene');
if (!scene) return;
const carrier = scene.querySelector('.thc-12__carrier');
const faces = carrier.querySelectorAll('.thc-12__face');
const btns = carrier.querySelectorAll('.thc-12__flipbtn');
let flipped = false;
const setFlip = (f) => {
flipped = f;
carrier.style.setProperty('--flip', f ? '180deg' : '0deg');
faces[0].setAttribute('aria-hidden', String(f));
faces[1].setAttribute('aria-hidden', String(!f));
btns.forEach((b) => b.setAttribute('aria-pressed', String(f)));
carrier.classList.add('is-flipping'); // force the slow bezier for the swing
carrier.classList.remove('is-live');
setTimeout(() => carrier.classList.remove('is-flipping'), 700);
};
btns.forEach((b) => b.addEventListener('click', () => setFlip(!flipped)));
if (!matchMedia('(hover:hover) and (pointer:fine)').matches) return;
if (matchMedia('(prefers-reduced-motion: reduce)').matches) return;
const MAX = 10;
scene.addEventListener('pointermove', (e) => {
if (carrier.classList.contains('is-flipping')) return; // let the swing finish clean
const r = scene.getBoundingClientRect();
const px = (e.clientX - r.left) / r.width, py = (e.clientY - r.top) / r.height;
const dir = flipped ? -1 : 1; // pointer-left is back-face-right
carrier.style.setProperty('--rx', ((0.5 - py) * MAX).toFixed(2) + 'deg');
carrier.style.setProperty('--ry', (dir * (px - 0.5) * MAX).toFixed(2) + 'deg');
carrier.classList.add('is-live');
});
scene.addEventListener('pointerleave', () => {
carrier.classList.remove('is-live');
carrier.style.setProperty('--rx', '0deg');
carrier.style.setProperty('--ry', '0deg');
});
})();
```Here's a working CSS 3D Tilt Hover Card from CodeFronts. Use it as-is or adapt to your framework. All classes are scoped under a unique prefix so the code won't collide with your existing styles. MIT licensed.
Demo: Flip-to-Back 3D Tilt Card
Source: https://codefronts.com/components/css-3d-tilt-hover-cards/flip-to-back-3d-tilt-card/
Tilt and flip in one transform: the card follows the cursor like any tilt card, but click (or press Enter/Space) and it swings a full 180° to reveal contact details on its back — and keeps tilting while flipped. The trick is composing rotateY(flip + tilt) as a single calc(), never two competing transforms.
## HTML
```html
<section class="thc-12" aria-label="Flip-to-back tilt card demo">
<div class="thc-12__scene">
<div class="thc-12__carrier">
<article class="thc-12__face thc-12__face--front" aria-hidden="false">
<p class="thc-12__kicker">FREELANCE</p>
<h3 class="thc-12__name">Jonah Reyes</h3>
<p class="thc-12__role">Motion & Brand Designer</p>
<button class="thc-12__flipbtn" type="button" aria-pressed="false">Contact ↻</button>
</article>
<article class="thc-12__face thc-12__face--back" aria-hidden="true">
<p class="thc-12__kicker">SAY HELLO</p>
<ul class="thc-12__contacts">
<li>jonah@studioreyes.co</li>
<li>+1 (415) 555-0132</li>
<li>@jonahmakes</li>
</ul>
<button class="thc-12__flipbtn thc-12__flipbtn--back" type="button" aria-pressed="true">Back ↺</button>
</article>
</div>
</div>
</section>
```
## CSS
```css
.thc-12,
.thc-12 *,
.thc-12 *::before,
.thc-12 *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
.thc-12 {
--acc: oklch(0.75 0.14 80);
font-family: 'Segoe UI',system-ui,sans-serif;
width: 100%;
min-height: 100vh;
display: grid;
place-items: center;
padding: 48px 24px;
background: radial-gradient(80% 90% at 70% 20%,oklch(0.25 0.05 80 / .7),transparent 60%),oklch(0.15 0.015 270);
}
.thc-12 .thc-12__scene {
perspective: 1100px;
width: min(300px,100%);
}
.thc-12 .thc-12__carrier {
position: relative;
height: 200px;
transform-style: preserve-3d;
transform: rotateX(var(--rx,0deg)) rotateY(calc(var(--flip,0deg) + var(--ry,0deg)));
transition: transform .7s cubic-bezier(.3,.8,.3,1.1);
}
.thc-12 .thc-12__carrier.is-live {
transition: transform .09s linear;
}
.thc-12 .thc-12__carrier.is-flipping {
transition: transform .7s cubic-bezier(.3,.8,.3,1.1);
}
.thc-12 .thc-12__face {
position: absolute;
inset: 0;
border-radius: 18px;
padding: 26px;
backface-visibility: hidden;
-webkit-backface-visibility: hidden;
background: linear-gradient(160deg,oklch(0.23 0.03 80),oklch(0.17 0.02 270));
border: 1px solid color-mix(in oklab,var(--acc) 35%,transparent);
box-shadow: 0 26px 54px -26px rgba(0,0,0,.8);
display: flex;
flex-direction: column;
}
.thc-12 .thc-12__face--back {
transform: rotateY(180deg);
}
.thc-12 .thc-12__kicker {
font: 700 10px ui-monospace,monospace;
letter-spacing: .2em;
color: var(--acc);
}
.thc-12 .thc-12__name {
margin-top: 12px;
font-size: 24px;
font-weight: 800;
color: #fff;
}
.thc-12 .thc-12__role {
margin-top: 4px;
font-size: 13px;
color: rgba(255,255,255,.6);
}
.thc-12 .thc-12__contacts {
list-style: none;
margin-top: 14px;
display: flex;
flex-direction: column;
gap: 8px;
font-size: 13.5px;
color: rgba(255,255,255,.85);
}
.thc-12 .thc-12__flipbtn {
margin-top: auto;
align-self: flex-start;
font: 600 12px 'Segoe UI',sans-serif;
color: oklch(0.15 0.02 270);
background: var(--acc);
border: none;
border-radius: 999px;
padding: 9px 16px;
cursor: pointer;
transition: transform .2s ease;
}
.thc-12 .thc-12__flipbtn:hover {
transform: scale(1.05);
}
.thc-12 .thc-12__flipbtn:focus-visible {
outline: 3px solid #fff;
outline-offset: 3px;
}
@media (prefers-reduced-motion: reduce) {
.thc-12 .thc-12__carrier {
transition: none;
}
}
```
## JavaScript
```js
(() => {
const scene = document.querySelector('.thc-12 .thc-12__scene');
if (!scene) return;
const carrier = scene.querySelector('.thc-12__carrier');
const faces = carrier.querySelectorAll('.thc-12__face');
const btns = carrier.querySelectorAll('.thc-12__flipbtn');
let flipped = false;
const setFlip = (f) => {
flipped = f;
carrier.style.setProperty('--flip', f ? '180deg' : '0deg');
faces[0].setAttribute('aria-hidden', String(f));
faces[1].setAttribute('aria-hidden', String(!f));
btns.forEach((b) => b.setAttribute('aria-pressed', String(f)));
carrier.classList.add('is-flipping'); // force the slow bezier for the swing
carrier.classList.remove('is-live');
setTimeout(() => carrier.classList.remove('is-flipping'), 700);
};
btns.forEach((b) => b.addEventListener('click', () => setFlip(!flipped)));
if (!matchMedia('(hover:hover) and (pointer:fine)').matches) return;
if (matchMedia('(prefers-reduced-motion: reduce)').matches) return;
const MAX = 10;
scene.addEventListener('pointermove', (e) => {
if (carrier.classList.contains('is-flipping')) return; // let the swing finish clean
const r = scene.getBoundingClientRect();
const px = (e.clientX - r.left) / r.width, py = (e.clientY - r.top) / r.height;
const dir = flipped ? -1 : 1; // pointer-left is back-face-right
carrier.style.setProperty('--rx', ((0.5 - py) * MAX).toFixed(2) + 'deg');
carrier.style.setProperty('--ry', (dir * (px - 0.5) * MAX).toFixed(2) + 'deg');
carrier.classList.add('is-live');
});
scene.addEventListener('pointerleave', () => {
carrier.classList.remove('is-live');
carrier.style.setProperty('--rx', '0deg');
carrier.style.setProperty('--ry', '0deg');
});
})();
```How this works
Flip state lives in one custom property: --flip is 0deg or 180deg, toggled by a real <button> via aria-pressed. The inner face-carrier's transform is rotateY(calc(var(--flip) + var(--ry))) rotateX(var(--rx)) — flip and tilt composed in ONE declaration, so both share the same transition and can't fight. Front and back faces each carry backface-visibility:hidden, with the back pre-rotated 180°.
One subtlety: when the card is flipped, horizontal tilt must invert (the pointer's left is the back face's right), so the JS multiplies --ry by -1 while flipped. The flip transition is a slower 700ms bezier layered over the fast tilt updates — CSS resolves both on the same property smoothly because the values compose additively in the calc().
Make it yours
- Change the flip axis to X (business-card style top-flip) by moving --flip into rotateX.
- Trigger flip on hover-intent instead of click: setTimeout 300ms on pointerenter, clear on leave.
- Put a QR code, vCard button, or form on the back — it's a normal DOM face, fully interactive when shown.
- Slow the flip to 900ms for a weightier card stock feel.
Gotchas — read before shipping
- Never put backface-visibility on the carrier — it belongs on each FACE, or the back face vanishes entirely.
- Invert the Y tilt while flipped or the card feels haunted (it tilts away from the cursor).
- The flip trigger must be a real button with aria-pressed — a click-anywhere flip is invisible to keyboards and screen readers.
Browser support
| Chrome | Safari | Firefox | Edge |
|---|---|---|---|
| 111+ | 16.4+ | 113+ | 111+ |
calc() inside rotateY() with custom properties works in all evergreens; the classic flip itself dates to 2012.
Techniques used in this demo
3D transforms (perspective + preserve-3d)color-mix()MDN ↗radial-gradient()MDN ↗place-items / place-contentMDN ↗oklch()MDN ↗