A 3D tilt card needs three pieces: a wrapper that supplies perspective, a card that rotates with rotateX() and rotateY(), and a few lines of JavaScript that turn pointer position into bounded angles. This guide builds a complete profile card with raised layers, a smooth reset, and a reduced-motion fallback, and explains the CSS rules that most often break the effect.
How the effect works
CSS transforms can rotate and translate elements in 3D space, but 3D only looks like 3D once perspective is configured. A smaller perspective distance produces a stronger, more dramatic effect; a larger one looks subtler and flatter. The tilt itself is nothing more than a rotation around the X and Y axes that JavaScript updates as the pointer moves.
The code below is a standard implementation pattern. It has not been benchmarked here, so test it in the browsers and input types you target.
Step 1: Write the HTML
Use ordinary semantic markup. The card should read perfectly well with no effect applied.
#1 Best Overall
<div class="scene">
<article class="card" aria-label="Profile card">
<div class="card-bg"></div>
<img class="avatar" src="avatar.jpg" alt="Portrait of Ada Rivera" width="120" height="120">
<div class="info">
<h2>Ada Rivera</h2>
<p>Front-end engineer</p>
<a class="btn" href="/contact">Get in touch</a>
</div>
</article>
</div>
Replace the name, image and link with your own. The background lives in its own element so that clipping (rounded corners) can be applied there rather than on the card, for a reason explained in the troubleshooting section.
Step 2: Add the CSS
.scene {
display: grid;
place-items: center;
min-height: 100vh;
perspective: 900px;
}
.card {
--rx: 0deg;
--ry: 0deg;
position: relative;
width: 320px;
padding: 2rem;
text-align: center;
color: #fff;
transform-style: preserve-3d;
transform: rotateX(var(--rx)) rotateY(var(--ry));
transition: transform 0.4s ease-out;
will-change: transform;
}
.card.is-active { transition: transform 0.08s linear; }
.card-bg {
position: absolute;
inset: 0;
border-radius: 20px;
background: linear-gradient(145deg, #3b4cca, #1b1f4b);
box-shadow: 0 20px 40px rgb(0 0 0 / 0.35);
overflow: hidden;
}
.avatar {
position: relative;
border-radius: 50%;
transform: translateZ(60px);
}
.info { position: relative; transform: translateZ(35px); }
.btn {
display: inline-block;
margin-top: 1rem;
padding: 0.6rem 1.2rem;
border-radius: 999px;
background: #fff;
color: #1b1f4b;
text-decoration: none;
transform: translateZ(20px);
}
What each key line does
perspectiveon.scenegives the child its depth cue. Placing it on the parent is one of two approaches (see the comparison below).transform-style: preserve-3don.cardkeeps the avatar, text and button positioned in 3D space instead of being flattened into the card’s plane. MDN describes the property as setting whether an element’s children are positioned in 3D space or flattened in the element’s plane.translateZ()on the layers pushes foreground items toward the viewer, so they shift relative to the background as the card rotates. That parallax is what sells the depth.- Two transition speeds: a short one while the pointer is moving keeps tracking responsive, and a longer ease-out on release lets the card settle back smoothly.
Step 3: Add the JavaScript
const card = document.querySelector('.card');
const scene = document.querySelector('.scene');
const MAX_TILT = 12; // degrees; tune to taste
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
let frame = null;
function clamp(n, min, max) {
return Math.min(max, Math.max(min, n));
}
function onMove(e) {
if (reduceMotion.matches || e.pointerType === 'touch') return;
const rect = card.getBoundingClientRect();
// Position within the card, from -0.5 (left/top) to +0.5 (right/bottom)
const x = clamp((e.clientX - rect.left) / rect.width - 0.5, -0.5, 0.5);
const y = clamp((e.clientY - rect.top) / rect.height - 0.5, -0.5, 0.5);
cancelAnimationFrame(frame);
frame = requestAnimationFrame(() => {
card.classList.add('is-active');
card.style.setProperty('--ry', `${x * 2 * MAX_TILT}deg`);
card.style.setProperty('--rx', `${-y * 2 * MAX_TILT}deg`);
});
}
function reset() {
cancelAnimationFrame(frame);
card.classList.remove('is-active');
card.style.setProperty('--rx', '0deg');
card.style.setProperty('--ry', '0deg');
}
scene.addEventListener('pointermove', onMove);
scene.addEventListener('pointerleave', reset);
card.addEventListener('blur', reset);
Why the math looks like this
- Moving right should turn the right edge away from you, which is a positive
rotateY. Moving down should lift the bottom edge toward you, which is a negativerotateX. That is why the Y value is negated. - Normalizing to ±0.5 and multiplying by 2 gives a range of ±1, so the final angle never exceeds
MAX_TILT. Clamping guards against pointer positions just outside the card. - Writing CSS custom properties keeps all visual logic in the stylesheet; JavaScript only supplies two numbers.
requestAnimationFramelimits style updates to one per frame even if the pointer fires events faster.- Listening on
.scenerather than.cardavoids a feedback jitter: when a rotated card’s edge moves out from under a stationary pointer,pointerleaveon the card itself can fire and snap it back, then re-enter, repeatedly. The larger scene area is stable.
Choosing where perspective goes
| Approach | Where it goes | Best for |
|---|---|---|
perspective property |
On the parent of the transformed element (.scene) |
Layered cards; the vanishing point stays fixed while the card rotates inside it |
perspective() function |
First item in the card’s own transform list, e.g. transform: perspective(900px) rotateX(...) |
Self-contained single elements, or several cards that should each have their own vanishing point |
These are related but not interchangeable: the function belongs in the transformed element’s transform list, while the perspective and perspective-origin properties belong on the parent of a child transformed in 3D. If you use the function, it must come before the rotations in the list, because transform order matters.
Rank #2
- HTML CSS Design and Build Web Sites
- Comes with secure packaging
- It can be a gift option
Troubleshooting: the layers look flat
The most common failure is that the card tilts but the avatar and text do not pop out. Two causes account for most cases.
1. preserve-3d is missing on an intermediate element
transform-style is not inherited. If you wrap the avatar in an extra div inside the card, that wrapper also needs transform-style: preserve-3d, or its children are flattened.
The Tool Desk
Outbyte Driver Updater FREEScan for outdated or missing drivers - takes under a minuteDriver Scan →Outbyte PC Repair FREERepair Windows errors before they cause bigger problemsFix Now →Rank #3
2. An element has a flattening property
Certain values force the flattened result even when preserve-3d is set. MDN lists, among others, opacity below 1, a filter other than none, and overflow other than visible or clip. Inspect every ancestor between the scene and the layers. This is why the example puts overflow: hidden on .card-bg, a sibling with no 3D children, and not on .card. If you want a fade-in, animate it on .scene or its parent instead of the card, and verify the result.
Respecting motion preferences
MDN recommends considering prefers-reduced-motion to turn off animations and, where appropriate, offering a control to disable motion. The script above already skips tilting when the preference is set. Add a CSS guard so the transition and any residual transforms are also removed:
Rank #4
- Brand: Wiley
- Set of 2 Volumes
- A handy two-book set that uniquely combines related technologies Highly visual format and accessible language makes these books highly effective learning tools Perfect for beginning web designers and front-end developers
@media (prefers-reduced-motion: reduce) {
.card {
transition: none;
transform: none;
}
}
With transform: none on the card, the child translateZ layers collapse into a plain, static card, which is the correct fallback. If the user changes the OS setting while the page is open, the script reads reduceMotion.matches on each event, so it responds without a reload.
Design and usability decisions
- Angle range: 8–15 degrees is a reasonable starting range for a text-heavy card; larger angles make small text hard to read mid-tilt. Treat this as a design starting point, not a standard.
- Perspective distance: try 600px for a dramatic look and 1200px for a restrained one.
- Touch devices: the script ignores touch pointers so the card never interferes with scrolling. Tilt is decoration; nothing essential depends on hover. Device-orientation tilt is possible but requires a permission prompt on some platforms and is outside this tutorial.
- Keyboard users: the link remains focusable and visible regardless of tilt. Don’t hide any content behind hover.
- Performance: only
transformis animated, which browsers can usually handle without relayout. Usewill-changesparingly; a handful of cards is fine, dozens may not be.
Optional: a moving glare
Reuse the same pointer position for a highlight. In the JS, also set --gx and --gy as percentages ((x + 0.5) * 100 + '%'), then add to .card-bg:
Windows Errors? Fix Them Before They Spread
Repair common Windows errors and clear accumulated junk for a smoother, more stable PC - no reinstall needed.Free scan · no reinstallCrashes, No Sound, or Screen Glitches?
Random freezes, missing sound and display glitches usually trace back to one bad driver. Find and replace yours safely.Free scan · under a minuteBest Value
.card-bg::after {
content: "";
position: absolute;
inset: 0;
background: radial-gradient(circle at var(--gx, 50%) var(--gy, 0%),
rgb(255 255 255 / 0.25), transparent 55%);
pointer-events: none;
}
Because the card-bg element itself has no opacity or filter applied, this pseudo-element does not break the 3D hierarchy.
Quick Recap
Test before shipping
- Move the pointer across all four corners and confirm the angle never exceeds your maximum.
- Leave the scene quickly and confirm the card settles to flat.
- Toggle your OS “reduce motion” setting and confirm the card is static.
- Tab to the button and confirm it is visible and operable.
- Check Chrome, Firefox and Safari on desktop, plus a touch device, since this tutorial’s code has not been verified across a browser matrix.
Product prices and availability are accurate as of the date/time indicated and are subject to change. Any price and availability information displayed on Amazon at the time of purchase will apply.




