Layered depth card
An illustrated card whose layers slide by different amounts as the cursor moves across it, with a gentle tilt.
CSSJavaScriptIntermediate
<div class="scene"><div class="card" id="card">
<div class="layer sky" style="--d:4"></div>
<div class="layer sun" style="--d:10"></div>
<div class="layer far" style="--d:18"></div>
<div class="layer near" style="--d:34"></div>
<div class="label"><h2>Dusk Valley</h2><p>Move across the card</p></div>
</div></div>
.scene{perspective:900px}
.card{--mx:0;--my:0;position:relative;width:min(300px,82vw);aspect-ratio:3/4;border-radius:20px;overflow:hidden;
background:#140807;box-shadow:0 30px 60px -20px rgba(0,0,0,.8);
transform:rotateY(calc(var(--mx) * 8deg)) rotateX(calc(var(--my) * -8deg));
transition:transform .5s cubic-bezier(.2,.8,.2,1)}
.card.live{transition:transform .08s linear}
.layer{position:absolute;inset:-8%;
transform:translate3d(calc(var(--mx) * var(--d) * -1px), calc(var(--my) * var(--d) * -1px), 0);
transition:transform .5s cubic-bezier(.2,.8,.2,1)}
.card.live .layer{transition:transform .08s linear}
.sky{background:linear-gradient(#2a0f0b,#e5603a 85%)}
.sun{inset:auto;left:50%;top:22%;width:90px;height:90px;margin-left:-45px;border-radius:50%;background:#ffd166;box-shadow:0 0 50px #ffd166}
.far{background:#4e1408;clip-path:polygon(0 70%,22% 48%,45% 66%,70% 40%,100% 62%,100% 100%,0 100%)}
.near{background:#2a0f0b;clip-path:polygon(0 82%,28% 62%,52% 80%,80% 58%,100% 76%,100% 100%,0 100%)}
.label{position:absolute;left:0;right:0;bottom:0;padding:22px;z-index:2;
transform:translate3d(calc(var(--mx) * 6px), calc(var(--my) * 6px), 0)}
.label h2{margin:0;font-size:1.7rem}.label p{margin:2px 0 0;color:#cfc3c1;font-size:14px}
const card = document.getElementById('card');
if (matchMedia('(hover:hover) and (pointer:fine)').matches) {
card.addEventListener('pointermove', e => {
const r = card.getBoundingClientRect();
card.classList.add('live');
card.style.setProperty('--mx', ((e.clientX - r.left) / r.width - .5) * 2); // -1 to 1
card.style.setProperty('--my', ((e.clientY - r.top) / r.height - .5) * 2);
});
card.addEventListener('pointerleave', () => {
card.classList.remove('live');
card.style.setProperty('--mx', 0);
card.style.setProperty('--my', 0);
});
}
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Layered depth card</title>
<style>
*{box-sizing:border-box}
html,body{margin:0;height:100%}
body{display:grid;place-items:center;background:#140807;color:#fffffc;font:16px/1.5 system-ui,-apple-system,"Segoe UI",sans-serif}
.scene{perspective:900px}
.card{--mx:0;--my:0;position:relative;width:min(300px,82vw);aspect-ratio:3/4;border-radius:20px;overflow:hidden;
background:#140807;box-shadow:0 30px 60px -20px rgba(0,0,0,.8);
transform:rotateY(calc(var(--mx) * 8deg)) rotateX(calc(var(--my) * -8deg));
transition:transform .5s cubic-bezier(.2,.8,.2,1)}
.card.live{transition:transform .08s linear}
.layer{position:absolute;inset:-8%;
transform:translate3d(calc(var(--mx) * var(--d) * -1px), calc(var(--my) * var(--d) * -1px), 0);
transition:transform .5s cubic-bezier(.2,.8,.2,1)}
.card.live .layer{transition:transform .08s linear}
.sky{background:linear-gradient(#2a0f0b,#e5603a 85%)}
.sun{inset:auto;left:50%;top:22%;width:90px;height:90px;margin-left:-45px;border-radius:50%;background:#ffd166;box-shadow:0 0 50px #ffd166}
.far{background:#4e1408;clip-path:polygon(0 70%,22% 48%,45% 66%,70% 40%,100% 62%,100% 100%,0 100%)}
.near{background:#2a0f0b;clip-path:polygon(0 82%,28% 62%,52% 80%,80% 58%,100% 76%,100% 100%,0 100%)}
.label{position:absolute;left:0;right:0;bottom:0;padding:22px;z-index:2;
transform:translate3d(calc(var(--mx) * 6px), calc(var(--my) * 6px), 0)}
.label h2{margin:0;font-size:1.7rem}.label p{margin:2px 0 0;color:#cfc3c1;font-size:14px}
</style></head>
<body>
<div class="scene"><div class="card" id="card">
<div class="layer sky" style="--d:4"></div>
<div class="layer sun" style="--d:10"></div>
<div class="layer far" style="--d:18"></div>
<div class="layer near" style="--d:34"></div>
<div class="label"><h2>Dusk Valley</h2><p>Move across the card</p></div>
</div></div>
<script>
const card = document.getElementById('card');
if (matchMedia('(hover:hover) and (pointer:fine)').matches) {
card.addEventListener('pointermove', e => {
const r = card.getBoundingClientRect();
card.classList.add('live');
card.style.setProperty('--mx', ((e.clientX - r.left) / r.width - .5) * 2); // -1 to 1
card.style.setProperty('--my', ((e.clientY - r.top) / r.height - .5) * 2);
});
card.addEventListener('pointerleave', () => {
card.classList.remove('live');
card.style.setProperty('--mx', 0);
card.style.setProperty('--my', 0);
});
}
</script>
</body></html>
Good for
Feature cards, game and character cards and portfolio covers.
Watch out for
Drive every layer from two CSS variables (mouse x and y) and give each layer its own depth multiplier. Overflow hidden keeps the moving layers inside the card.
Accessibility
Wrap motion in @media (prefers-reduced-motion: reduce) and switch it off or shorten it for people who ask for less movement.