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.translate. Moving a transform is cheap; moving background-position would repaint.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 with translateZ(14px), so they parallax..is-live), slow and springy when it leaves, which is the glide back to rest.transform.pointer → --rx / --ry / --gx / --gyglare = oversized gradient moved with translatechip and text lifted with translateZslow transition at rest, fast while live<div class="scene">
<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 scene is also the pointer area around the card */
.scene {
padding: 70px 90px;
perspective: 800px;
touch-action: none;
}
.paycard {
width: 196px;
height: 124px;
transform-style: preserve-3d;
animation: idle 6s ease-in-out infinite alternate;
}
.card {
position: relative;
width: 100%;
height: 100%;
color: #fff;
font-family: system-ui, sans-serif;
transform-style: preserve-3d;
transform: rotateX(var(--rx, 0deg)) rotateY(var(--ry, 0deg));
transition: transform 0.6s cubic-bezier(0.2, 0.9, 0.3, 1.2);
}
.card.is-live {
transition-duration: 0.12s;
transition-timing-function: ease-out;
}
/* embossed details float above the surface */
.card > span {
position: absolute;
transform: translateZ(14px);
pointer-events: none;
}
.shadow {
position: absolute;
inset: 6px -4px -14px;
background: radial-gradient(ellipse at center, rgb(0 0 0 / 0.55), transparent 68%);
transform: translateZ(-40px);
}
/* only this flat layer clips; clipping the card itself would flatten it */
.face {
position: absolute;
inset: 0;
overflow: hidden;
border: 1px solid rgb(255 255 255 / 0.28);
border-radius: 14px;
background:
radial-gradient(circle at 0% 100%, rgb(46 230 214 / 0.7), transparent 55%),
radial-gradient(circle at 100% 0%, rgb(255 77 157 / 0.85), transparent 60%),
linear-gradient(135deg, #8b6cff, #453880);
}
/* twice the card: ±25% of itself walks the bright spot edge to edge */
.glare {
position: absolute;
inset: -50%;
background: radial-gradient(circle at center, rgb(255 255 255 / 0.6), rgb(255 255 255 / 0.12) 28%, transparent 48%);
transform: translate(calc(var(--gx, -14) * 1%), calc(var(--gy, -16) * 1%));
transition: transform 0.6s ease-out;
}
.card.is-live .glare {
transition-duration: 0.12s;
}
.chip {
top: 34px;
left: 18px;
width: 32px;
height: 24px;
border-radius: 5px;
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: 14px;
right: 16px;
width: 40px;
height: 24px;
}
.brand i {
position: absolute;
top: 0;
box-sizing: border-box;
width: 24px;
height: 24px;
border: 4px solid #fff;
border-radius: 50%;
}
.brand i:first-child { left: 0; }
.brand i:last-child { right: 0; border-color: #2ee6d6; }
.num {
left: 18px;
bottom: 36px;
font: 600 13px/1 ui-monospace, Consolas, monospace;
letter-spacing: 1.5px;
text-shadow: 0 1px 2px rgb(0 0 0 / 0.45);
white-space: nowrap;
}
.name,
.exp {
bottom: 15px;
font-size: 9px;
font-weight: 700;
letter-spacing: 1.2px;
opacity: 0.85;
}
.name { left: 18px; }
.exp { right: 18px; }
@keyframes idle {
from { transform: rotateX(7deg) rotateY(-12deg); }
to { transform: rotateX(3deg) rotateY(12deg); }
}
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);
.d-paycard {
width: 196px;
height: 124px;
transform-style: preserve-3d;
// A small idle sway so the card is alive before anyone touches it. It lives on the wrapper,
// the pointer tilt lives on the card inside, so the two never fight over `transform`.
animation: d-paycard-idle 6s ease-in-out infinite alternate;
touch-action: none;
&__card {
position: relative;
width: 100%;
height: 100%;
color: #fff;
transform-style: preserve-3d;
// JS only writes the four variables. Slow transition = the glide back to rest.
transform: rotateX(var(--rx, 0deg)) rotateY(var(--ry, 0deg));
transition: transform 0.6s cubic-bezier(0.2, 0.9, 0.3, 1.2);
// While the pointer is over the stage the card must follow it almost instantly.
&.is-live {
transition-duration: 0.12s;
transition-timing-function: ease-out;
}
> span {
position: absolute;
transform: translateZ(14px); // embossed: floats above the surface, so it parallaxes
pointer-events: none;
}
}
&__shadow {
position: absolute;
inset: 6px -4px -14px;
background: radial-gradient(ellipse at center, rgb(0 0 0 / 0.55), transparent 68%);
transform: translateZ(-40px);
}
// overflow: hidden would flatten a preserve-3d parent, so the clipping happens on this
// flat layer only. The floating details are its siblings, not its children.
&__face {
position: absolute;
inset: 0;
overflow: hidden;
border: 1px solid rgb(255 255 255 / 0.28);
border-radius: 14px;
background:
radial-gradient(circle at 0% 100%, color-mix(in srgb, var(--accent-2) 70%, transparent), transparent 55%),
radial-gradient(circle at 100% 0%, color-mix(in srgb, var(--hot) 85%, transparent), transparent 60%),
linear-gradient(135deg, var(--accent), color-mix(in srgb, var(--accent) 45%, #0b0d18));
}
// Twice the size of the card, so moving it by ±25% of itself walks the bright spot from one
// card edge to the other. --gx / --gy arrive as plain numbers between −25 and 25.
&__glare {
position: absolute;
inset: -50%;
background: radial-gradient(circle at center, rgb(255 255 255 / 0.6), rgb(255 255 255 / 0.12) 28%, transparent 48%);
transform: translate(calc(var(--gx, -14) * 1%), calc(var(--gy, -16) * 1%));
transition: transform 0.6s ease-out;
}
&__card.is-live &__glare {
transition-duration: 0.12s;
}
&__chip {
top: 34px;
left: 18px;
width: 32px;
height: 24px;
border-radius: 5px;
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, var(--warm) 55%, #b97a1c);
}
// brand mark: two overlapping rings
&__brand {
top: 14px;
right: 16px;
width: 40px;
height: 24px;
i {
position: absolute;
top: 0;
width: 24px;
height: 24px;
border: 4px solid #fff;
border-radius: 50%;
&:first-child {
left: 0;
}
&:last-child {
right: 0;
border-color: var(--accent-2);
}
}
}
&__num {
left: 18px;
bottom: 36px;
font: 600 13px/1 ui-monospace, 'SF Mono', Consolas, monospace;
letter-spacing: 1.5px;
text-shadow: 0 1px 2px rgb(0 0 0 / 0.45);
white-space: nowrap;
}
&__name,
&__exp {
bottom: 15px;
font-size: 9px;
font-weight: 700;
letter-spacing: 1.2px;
opacity: 0.85;
}
&__name {
left: 18px;
}
&__exp {
right: 18px;
}
}
@keyframes d-paycard-idle {
from {
transform: rotateX(7deg) rotateY(-12deg);
}
to {
transform: rotateX(3deg) rotateY(12deg);
}
}