Tilt Card with Glare
The card tips in 3D toward the pointer while a light spot slides across its surface like a reflection.
Make a React card that tilts in 3D toward the pointer, driven by CSS custom properties. On pointermove, convert the pointer position to values from -0.5 to 0.5 on each axis and write them to --px and --py on the card. In CSS, apply transform: perspective(800px) rotateX(calc(var(--py) * -12deg)) rotateY(calc(var(--px) * 12deg)) with a 150ms ease-out transition. Add a ::after glare layer with a radial-gradient of white at 35% opacity, positioned at the pointer using the same variables, and mix-blend-mode: soft-light. Reset both variables to 0 on pointerleave. Disable the tilt under prefers-reduced-motion. The card shows the label "Membership", the title "Studio Pass" and the line "Valid until Dec 2027".
Source
'use client'
import s from './demo.module.css'
export default function TiltCard({ labels }: { labels: Record<string, string> }) {
const onMove = (e: React.PointerEvent<HTMLDivElement>) => {
const el = e.currentTarget
const r = el.getBoundingClientRect()
el.style.setProperty('--px', ((e.clientX - r.left) / r.width - 0.5).toFixed(3))
el.style.setProperty('--py', ((e.clientY - r.top) / r.height - 0.5).toFixed(3))
}
const onLeave = (e: React.PointerEvent<HTMLDivElement>) => {
e.currentTarget.style.setProperty('--px', '0')
e.currentTarget.style.setProperty('--py', '0')
}
return (
<div className={s.card} onPointerMove={onMove} onPointerLeave={onLeave}>
<span className={s.kicker}>{labels.kicker}</span>
<strong className={s.name}>{labels.name}</strong>
<span className={s.meta}>{labels.meta}</span>
</div>
)
}
.card {
--px: 0;
--py: 0;
position: relative;
display: grid;
align-content: end;
gap: 4px;
width: min(260px, 100%);
aspect-ratio: 1.6;
padding: 20px;
overflow: hidden;
border-radius: 14px;
background: var(--stage-surface);
border: 1px solid var(--stage-rule);
color: var(--stage-ink);
transform: perspective(800px) rotateX(calc(var(--py) * -12deg)) rotateY(calc(var(--px) * 12deg));
transition: transform 150ms var(--ease-out);
}
.card::after {
content: '';
position: absolute;
inset: 0;
background: radial-gradient(
circle at calc((var(--px) + 0.5) * 100%) calc((var(--py) + 0.5) * 100%),
var(--stage-sheen),
transparent 55%
);
mix-blend-mode: soft-light;
pointer-events: none;
}
.kicker {
font: 500 var(--text-xs) / 1 var(--font-mono);
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--stage-muted);
}
.name {
font: 600 var(--text-xl) / 1.2 var(--font-display);
}
.meta {
font-size: var(--text-sm);
color: var(--stage-muted);
}
@media (prefers-reduced-motion: reduce) {
.card { transform: none; }
}