Phone mockup
Pure CSSA phone with real thickness: screen, back, four thin walls and a stack of rounded slabs that fills the corners. It turns to show both sides.
The card leans toward your pointer and a glare follows it. JS writes four custom properties; CSS does the tilt, the glare and the return to rest.
Your edited version
--rx / --ry for the tilt, --gx / --gy for the glare. CSS does everything else.--gx / --gy are registered with @property as numbers, so they interpolate: the glide back to rest is a transition on two numbers, and the layer never grows past the card.overflow: hidden would flatten the 3D card, so only the flat face clips (the glare lives inside it). The chip and the text are siblings lifted 14 units with translateZ, so they parallax..is-live), slow and springy when it leaves, which is the glide back to rest.transform.--u, tied to the canvas, so the card, its type and its lift are the same share of a gallery card, the editor and a recording canvas.pointer → --rx / --ry / --gx / --gyglare = gradient whose centre follows the pointerchip and text lifted with translateZslow transition at rest, fast while live<div class="scene" tabindex="0" role="img" aria-label="Payment card that tilts toward the pointer">
<div class="paycard">
<div class="card">
<i class="shadow"></i>
<div class="face"><i class="glare"></i></div>
<span class="chip"></span>
<span class="brand"><i></i><i></i></span>
<span class="num">•••• •••• •••• 4242</span>
<span class="name">ALEX MORGAN</span>
<span class="exp">09/29</span>
</div>
</div>
</div>
/* the bright spot's centre, as two plain numbers. Registering them makes them interpolate,
so the glare glides back to rest on a transition like any length would */
@property --gx { syntax: '<number>'; inherits: true; initial-value: -14; }
@property --gy { syntax: '<number>'; inherits: true; initial-value: -16; }
/* the scene is the whole canvas, so the pointer is tracked everywhere and the card is centred */
.scene {
/* one base unit: every length below is a multiple of it, so the card is the same share of a
gallery card, the editor, a full screen and a recording canvas */
--u: 0.4vmin;
display: grid;
place-items: center;
width: 100%;
height: 100%;
perspective: calc(800 * var(--u));
touch-action: none;
}
.paycard {
width: calc(196 * var(--u));
height: calc(124 * var(--u));
transform-style: preserve-3d;
animation: idle 6s ease-in-out infinite alternate;
}
.card {
position: relative;
width: 100%;
height: 100%;
color: #fff;
font-family: Inter, system-ui, sans-serif;
transform-style: preserve-3d;
transform: rotateX(var(--rx, 0deg)) rotateY(var(--ry, 0deg));
/* the tilt and the glare's centre glide together, so one duration drives both */
transition:
transform 0.6s cubic-bezier(0.2, 0.9, 0.3, 1.2),
--gx 0.6s ease-out,
--gy 0.6s ease-out;
}
.card.is-live {
transition-duration: 0.12s;
transition-timing-function: ease-out;
}
/* embossed details float above the surface */
.card > span {
position: absolute;
transform: translateZ(calc(14 * var(--u)));
pointer-events: none;
}
.shadow {
position: absolute;
inset: calc(6 * var(--u)) calc(-4 * var(--u)) calc(-14 * var(--u));
background: radial-gradient(ellipse at center, rgb(0 0 0 / 0.55), transparent 68%);
transform: translateZ(calc(-40 * var(--u)));
}
/* only this flat layer clips; clipping the card itself would flatten it */
.face {
position: absolute;
inset: 0;
overflow: hidden;
border: calc(1 * var(--u)) solid rgb(255 255 255 / 0.28);
border-radius: calc(14 * var(--u));
background:
/* deep glows, so the white name and date read over them: the bright teal glow under the name
made it 2.3:1 */
radial-gradient(circle at 0% 100%, rgb(8 122 112 / 0.85), transparent 55%),
radial-gradient(circle at 100% 0%, rgb(209 32 111 / 0.85), transparent 60%),
linear-gradient(135deg, #6a45f5, #453880);
}
/* exactly the card: only the circle's centre walks, from one edge to the other */
.glare {
position: absolute;
inset: 0;
background: radial-gradient(
circle at calc(50% + var(--gx) * 2%) calc(50% + var(--gy) * 2%),
rgb(255 255 255 / 0.5), rgb(255 255 255 / 0.05) 56%, transparent 96%); /* soft enough that the white name and date read through it */
}
.chip {
top: calc(34 * var(--u));
left: calc(18 * var(--u));
width: calc(32 * var(--u));
height: calc(24 * var(--u));
border-radius: calc(5 * var(--u));
background:
linear-gradient(90deg, transparent 31%, rgb(0 0 0 / 0.35) 31% 34%, transparent 34% 66%, rgb(0 0 0 / 0.35) 66% 69%, transparent 69%),
linear-gradient(transparent 46%, rgb(0 0 0 / 0.35) 46% 54%, transparent 54%),
linear-gradient(135deg, #ffe9b0, #ffb547 55%, #b97a1c);
}
.brand {
top: calc(14 * var(--u));
right: calc(16 * var(--u));
width: calc(40 * var(--u));
height: calc(24 * var(--u));
}
.brand i {
position: absolute;
top: 0;
box-sizing: border-box;
width: calc(24 * var(--u));
height: calc(24 * var(--u));
border: calc(4 * var(--u)) solid #fff;
border-radius: 50%;
}
.brand i:first-child { left: 0; }
.brand i:last-child { right: 0; border-color: #2ee6d6; }
.num {
left: calc(18 * var(--u));
bottom: calc(36 * var(--u));
/* 12, not 13: JetBrains Mono's advance is 0.6em against Consolas's 0.55, so at 13 the 19
characters of the number ran to 194.7 of the card's 196 units and sat on its edge.
The tracking is the expiry's, not a value of its own, because that is what makes the two
right edges agree: the number's last digit lands at 176.4 units and the expiry's at 176.8,
while both lines still start their run at the same 18 the name does. */
font: 600 calc(12 * var(--u))/1 'JetBrains Mono', ui-monospace, Consolas, monospace;
letter-spacing: calc(1.2 * var(--u));
text-shadow: 0 calc(1 * var(--u)) calc(2 * var(--u)) rgb(0 0 0 / 0.45);
white-space: nowrap;
}
.name,
.exp {
bottom: calc(15 * var(--u));
font-size: calc(9 * var(--u));
font-weight: 700;
letter-spacing: calc(1.2 * var(--u)); /* full white: at 0.85, under the glare, it was 2.3:1 */
}
.name { left: calc(18 * var(--u)); }
.exp { right: calc(18 * var(--u)); }
@keyframes idle {
from { transform: rotateX(7deg) rotateY(-12deg); }
to { transform: rotateX(3deg) rotateY(12deg); }
}
/* the keyboard: Tab to it and it leans as it would for a pointer near the top right, even with
the pointer resting on the canvas (!important beats the pose the script writes inline) */
.scene:focus-visible .card { --rx: 8deg !important; --ry: 12deg !important; --gx: 14 !important; --gy: -10 !important; }
const scene = document.querySelector('.scene');
const card = document.querySelector('.card');
const clamp = (v) => Math.min(0.5, Math.max(-0.5, v));
function move(e) {
const r = scene.getBoundingClientRect();
// -0.5 … 0.5 across the scene
const x = clamp((e.clientX - r.left) / r.width - 0.5);
const y = clamp((e.clientY - r.top) / r.height - 0.5);
card.style.setProperty('--ry', (x * 44).toFixed(1) + 'deg');
card.style.setProperty('--rx', (-y * 34).toFixed(1) + 'deg');
card.style.setProperty('--gx', (x * 50).toFixed(1)); // plain numbers, CSS makes them %
card.style.setProperty('--gy', (y * 50).toFixed(1));
card.classList.add('is-live');
}
function leave() {
card.classList.remove('is-live');
for (const p of ['--rx', '--ry', '--gx', '--gy']) card.style.removeProperty(p);
}
scene.addEventListener('pointermove', move);
scene.addEventListener('pointerdown', move);
scene.addEventListener('pointerleave', leave);
scene.addEventListener('pointercancel', leave);