A bakery puts its weekend box on the home page, and it sits there as one more flat rectangle among the photos and opening hours. People scroll straight past it. This card gives the offer a little physical presence: as the pointer moves over it, the card tilts and its layers separate, so the badge, the headline, the price and the button float at different heights above the green panel. It feels like a printed card picked up off a counter, and it stays a normal block of text and a real button underneath.
Weekend only
Sourdough box for four
$24 two loaves, four buns, a jar of butter
Move your pointer over the card or drag across it on a phone. On a keyboard, focus the card and use the arrow keys.
How it works
- One shared perspective. The wrapper sets
perspective, and the card usestransform-style: preserve-3d, so every layer inside is drawn in the same 3D space instead of being flattened. - Layers at different depths. Each child gets its own
--zvalue and is pushed forward withtranslateZ. When the card is flat you cannot see the gaps. When it tilts, the nearer layers shift more than the panel behind them, which is what makes it read as depth. - Pointer to angle. The script measures where the pointer sits on the card, turns that into a number from minus one to one on each axis, and multiplies it by the largest tilt allowed.
- Eased, then stopped. The card moves a fraction of the way to its target on each frame, which smooths out jumpy mouse movement. Once it has settled, the animation loop ends, so an idle card costs nothing.
- A light that follows. A soft radial highlight moves with the tilt, a small detail that sells the glossy card feeling.
- Every input. Touch tilts the card where you press or drag, arrow keys nudge it when the card has focus, and Escape or moving away flattens it again. The first time the card scrolls into view it sways once as a hint, and that hint stops the moment someone takes over.
The code
The three blocks below are the complete demo, with no libraries and nothing loaded from elsewhere.
<div class="dp" data-lab="depth-card">
<div class="dp-scene">
<div class="dp-card" tabindex="0" role="group" aria-label="Weekend offer card. Use the arrow keys to tilt it." aria-describedby="dp-help">
<div class="dp-back" aria-hidden="true"></div>
<div class="dp-sun" aria-hidden="true"></div>
<p class="dp-tag">Weekend only</p>
<h3 class="dp-title">Sourdough box for four</h3>
<p class="dp-price">$24 <span>two loaves, four buns, a jar of butter</span></p>
<button class="dp-cta" type="button">Reserve a box</button>
<div class="dp-glare" aria-hidden="true"></div>
</div>
</div>
<p id="dp-help" class="dp-help">Move your pointer over the card or drag across it on a phone. On a keyboard, focus the card and use the arrow keys.</p>
<p class="dp-status" aria-live="polite"></p>
</div>.dp {
display: grid;
justify-items: center;
gap: 14px;
padding: 18px 0 6px;
font-family: system-ui, sans-serif;
color: #101a14;
}
/* Perspective lives on the parent, so the card and its layers share one vanishing point. */
.dp-scene { width: 100%; max-width: 380px; perspective: 900px; }
.dp-card {
--rx: 0deg;
--ry: 0deg;
--gx: 50%;
--gy: 50%;
position: relative;
display: grid;
align-content: end;
gap: 8px;
box-sizing: border-box;
padding: 132px 26px 26px;
border-radius: 20px;
transform: rotateX(var(--rx)) rotateY(var(--ry));
transform-style: preserve-3d;
touch-action: pan-y;
outline: none;
}
.dp-card:focus-visible { box-shadow: 0 0 0 3px #fff, 0 0 0 6px #133621; }
/* Every layer sits at its own depth; the tilt makes the gaps between them visible. */
.dp-card > * { position: relative; transform: translateZ(var(--z, 0px)); }
.dp-back {
position: absolute;
inset: 0;
border-radius: 20px;
background: radial-gradient(circle at 20% 15%, #1f5233, #133621 60%);
box-shadow: 0 22px 40px -18px rgba(16, 26, 20, .55);
}
.dp-sun {
position: absolute;
top: 24px;
right: 24px;
width: 88px;
height: 88px;
border-radius: 50%;
background: #f4d96c;
box-shadow: 0 10px 24px rgba(0, 0, 0, .25);
--z: 30px;
}
.dp[data-lab] .dp-tag {
justify-self: start;
margin: 0;
padding: 5px 12px;
border-radius: 999px;
background: rgba(244, 217, 108, .16);
color: #f4d96c;
font: 600 13px/1.2 system-ui, sans-serif;
letter-spacing: .04em;
text-transform: uppercase;
--z: 45px;
}
.dp[data-lab] .dp-title { margin: 0; color: #fff; font: 700 28px/1.15 system-ui, sans-serif; --z: 70px; }
.dp[data-lab] .dp-price { margin: 0 0 6px; color: #f4d96c; font: 700 22px/1.3 system-ui, sans-serif; --z: 55px; }
.dp[data-lab] .dp-price span { display: block; color: #dfe6dc; font-size: 15px; font-weight: 400; }
.dp[data-lab] .dp-cta {
justify-self: start;
margin: 0;
padding: 12px 20px;
border: 0;
border-radius: 12px;
background: #f4d96c;
color: #133621;
font: 700 16px/1 system-ui, sans-serif;
cursor: pointer;
box-shadow: 0 8px 18px rgba(0, 0, 0, .3);
--z: 85px;
}
.dp[data-lab] .dp-cta:focus-visible { outline: 3px solid #fff; outline-offset: 3px; }
/* A soft highlight that follows the tilt, like light on a glossy card. */
.dp-glare {
position: absolute;
inset: 0;
border-radius: 20px;
background: radial-gradient(circle at var(--gx) var(--gy), rgba(255, 255, 255, .22), transparent 55%);
pointer-events: none;
--z: 1px;
}
.dp[data-lab] .dp-help { width: min(100%, 380px); margin: 0; color: #46534a; font-size: 14px; line-height: 1.5; text-align: center; }
.dp[data-lab] .dp-status { min-height: 1.4em; margin: 0; color: #133621; font-size: 15px; font-weight: 600; text-align: center; }
@media (prefers-reduced-motion: reduce) {
.dp-card { transform: none; }
.dp-glare { display: none; }
}(function () {
var root = document.querySelector('[data-lab="depth-card"]');
if (!root) return;
var card = root.querySelector('.dp-card');
var status = root.querySelector('.dp-status');
var MAX = 14; // largest tilt in degrees
var EASE = 0.12; // how quickly the card follows, 0 to 1
var calm = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var tx = 0, ty = 0, x = 0, y = 0, raf = 0, touched = false;
// Paint the current tilt. x and y run from -1 to 1.
function paint() {
card.style.setProperty('--rx', (-y * MAX).toFixed(2) + 'deg');
card.style.setProperty('--ry', (x * MAX).toFixed(2) + 'deg');
card.style.setProperty('--gx', (50 + x * 40).toFixed(1) + '%');
card.style.setProperty('--gy', (50 + y * 40).toFixed(1) + '%');
}
// Ease towards the target and stop the loop once the card has settled.
function frame() {
x += (tx - x) * EASE;
y += (ty - y) * EASE;
paint();
if (Math.abs(tx - x) > 0.002 || Math.abs(ty - y) > 0.002) raf = requestAnimationFrame(frame);
else raf = 0;
}
function aim(nx, ny) {
if (calm) return;
tx = Math.max(-1, Math.min(1, nx));
ty = Math.max(-1, Math.min(1, ny));
if (!raf) raf = requestAnimationFrame(frame);
}
// Turn a pointer position into a tilt target relative to the card centre.
function fromPointer(e) {
touched = true;
var r = card.getBoundingClientRect();
aim((e.clientX - r.left) / r.width * 2 - 1, (e.clientY - r.top) / r.height * 2 - 1);
}
card.addEventListener('pointermove', fromPointer);
card.addEventListener('pointerdown', fromPointer);
card.addEventListener('pointerleave', function () { aim(0, 0); });
card.addEventListener('pointercancel', function () { aim(0, 0); });
// Arrow keys nudge the tilt; Escape flattens it again.
card.addEventListener('keydown', function (e) {
if (e.target !== card) return;
touched = true;
var step = 0.35, k = e.key;
if (k === 'ArrowLeft') aim(tx - step, ty);
else if (k === 'ArrowRight') aim(tx + step, ty);
else if (k === 'ArrowUp') aim(tx, ty - step);
else if (k === 'ArrowDown') aim(tx, ty + step);
else if (k === 'Escape') aim(0, 0);
else return;
e.preventDefault();
});
card.addEventListener('blur', function () { aim(0, 0); });
root.querySelector('.dp-cta').addEventListener('click', function () {
status.textContent = 'Box reserved. This is a demo, so nothing was booked.';
});
// Each step of the hint is skipped once the reader has taken over.
function sway(nx, ny, wait) {
setTimeout(function () { if (!touched) aim(nx, ny); }, wait);
}
// One small sway the first time the card scrolls into view, as a hint that it moves.
if (!calm && 'IntersectionObserver' in window) {
var io = new IntersectionObserver(function (entries) {
if (!entries[0].isIntersecting) return;
io.disconnect();
sway(0.7, -0.4, 0);
sway(-0.6, 0.3, 450);
sway(0, 0, 900);
}, { threshold: 0.6 });
io.observe(card);
}
})();When to use it, and when not to
It works best on one hero item per page: a seasonal offer, a gift card, a membership tier, a featured service at a salon or a studio. The movement draws the eye to the one thing you most want read, and because the content is real HTML, search engines and screen readers get the same text everyone else does.
It is a poor fit for a grid of many cards. Ten tilting cards turn a page into a funhouse, and the effect stops meaning "this one matters". It also adds nothing to dense information such as a price list or a booking form, where people want to read steadily, not chase moving text. Keep the tilt small too; past about 15 degrees the text starts to look skewed and harder to read.
On accessibility, the card respects the reduced motion setting by staying completely flat, and the hint sway never runs for those readers. The button inside is a normal focusable button, so nobody has to tilt anything to use the offer. On phones the card allows vertical scrolling, so a thumb passing over it still scrolls the page; the tilt is a bonus there, not a requirement.
Make it yours
- Strength of the tilt: change
MAXin the script. Between 8 and 15 degrees keeps text readable. - Smoothness:
EASEcontrols how quickly the card catches up. Lower values feel heavier and calmer; higher values feel snappy. - Depth of each layer: edit the
--zvalues in the CSS, such as--z: 70pxon.dp-title. Bigger gaps between layers give a stronger 3D look.
Frequently asked questions
Does it need a 3D library?
No. It uses plain CSS 3D transforms that every current browser supports, with a short script that only updates a few custom properties.
Will it slow my page down?
Transforms are cheap for the browser to draw, and the animation loop only runs while the card is moving. When the card is still, the script does nothing at all.
Can I put a photo on the card instead of the gold circle?
Yes. Replace the .dp-sun element with an image, give it its own --z value, and add alt text if the photo carries meaning.