3D Tilt Card with Glare
Pointer-driven 3D tilt cards that rotate toward the cursor with a moving specular glare, layered parallax depth, and a smooth spring-back on leave — pure CSS transforms and vanilla JS.
MCP
Code
:root {
--bg: #0c0d10;
--surface: #15161b;
--surface-2: #1d1f27;
--ink: #e9eaf0;
--muted: #9a9cab;
--accent: #8b5cf6;
--accent-2: #22d3ee;
--ok: #34d399;
--warn: #fbbf24;
--danger: #f87171;
--line: rgba(255, 255, 255, 0.1);
--line-2: rgba(255, 255, 255, 0.18);
--r-sm: 8px;
--r-md: 12px;
--r-lg: 16px;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
html {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
margin: 0;
min-height: 100vh;
background:
radial-gradient(1100px 600px at 15% -10%, rgba(139, 92, 246, 0.16), transparent 60%),
radial-gradient(900px 600px at 100% 0%, rgba(34, 211, 238, 0.12), transparent 55%),
var(--bg);
color: var(--ink);
font-family: "Inter", system-ui, sans-serif;
line-height: 1.5;
}
.page {
max-width: 1080px;
margin: 0 auto;
padding: clamp(24px, 5vw, 64px) clamp(16px, 4vw, 40px) 48px;
}
/* ---------- Masthead ---------- */
.masthead {
margin-bottom: clamp(32px, 6vw, 56px);
}
.eyebrow {
margin: 0 0 10px;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--accent-2);
}
.masthead h1 {
margin: 0 0 12px;
font-size: clamp(28px, 5vw, 42px);
font-weight: 700;
letter-spacing: -0.02em;
}
.lede {
margin: 0;
max-width: 60ch;
color: var(--muted);
font-size: 15px;
}
/* ---------- Controls ---------- */
.controls {
display: flex;
flex-wrap: wrap;
gap: 14px 22px;
align-items: center;
margin-top: 22px;
padding: 14px 18px;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-md);
}
.control {
display: inline-flex;
align-items: center;
gap: 10px;
font-size: 13px;
color: var(--muted);
}
.control__label {
font-weight: 500;
color: var(--ink);
white-space: nowrap;
}
.control__val {
min-width: 34px;
font-family: "JetBrains Mono", monospace;
font-size: 12px;
color: var(--accent-2);
}
input[type="range"] {
-webkit-appearance: none;
appearance: none;
width: 140px;
height: 4px;
border-radius: 999px;
background: linear-gradient(90deg, var(--accent), var(--accent-2));
outline: none;
cursor: pointer;
}
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--ink);
border: 2px solid var(--accent);
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.4);
}
input[type="range"]::-moz-range-thumb {
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--ink);
border: 2px solid var(--accent);
}
.control--switch {
cursor: pointer;
}
.control--switch input {
width: 16px;
height: 16px;
accent-color: var(--accent);
cursor: pointer;
}
:focus-visible {
outline: 2px solid var(--accent-2);
outline-offset: 3px;
}
/* ---------- Deck ---------- */
.deck {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: clamp(24px, 4vw, 40px);
perspective: 1000px;
}
/* ---------- Card ---------- */
.tilt-card {
--rx: 0deg;
--ry: 0deg;
--mx: 50%;
--my: 50%;
--glare: 0;
--pop: 0;
position: relative;
border-radius: var(--r-lg);
cursor: pointer;
outline-offset: 6px;
transform-style: preserve-3d;
}
.tilt-card__inner {
position: relative;
border-radius: inherit;
background:
linear-gradient(160deg, var(--surface-2), var(--surface));
border: 1px solid var(--line-2);
transform: rotateX(var(--rx)) rotateY(var(--ry));
transform-style: preserve-3d;
transition: transform 0.5s cubic-bezier(0.23, 1.2, 0.32, 1);
box-shadow:
0 10px 30px rgba(0, 0, 0, 0.45),
0 2px 6px rgba(0, 0, 0, 0.3);
overflow: hidden;
will-change: transform;
}
.tilt-card:hover .tilt-card__inner,
.tilt-card:focus-visible .tilt-card__inner,
.tilt-card.is-active .tilt-card__inner {
transition: transform 0.08s linear;
box-shadow:
0 24px 60px rgba(0, 0, 0, 0.55),
0 0 0 1px var(--line-2);
}
/* accent edge glow per card */
.tilt-card[data-accent="violet"] .tilt-card__inner {
box-shadow:
0 24px 60px rgba(139, 92, 246, 0.18),
0 10px 30px rgba(0, 0, 0, 0.45);
}
/* Glare */
.tilt-card__glare {
position: absolute;
inset: 0;
border-radius: inherit;
pointer-events: none;
z-index: 3;
opacity: var(--glare);
transition: opacity 0.4s ease;
background: radial-gradient(
260px circle at var(--mx) var(--my),
rgba(255, 255, 255, 0.42),
rgba(255, 255, 255, 0.08) 30%,
transparent 60%
);
mix-blend-mode: overlay;
}
/* Content + parallax */
.tilt-card__content {
position: relative;
z-index: 2;
padding: 22px;
transform-style: preserve-3d;
}
.badge {
display: inline-block;
padding: 5px 11px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.04em;
border-radius: 999px;
color: var(--accent);
background: rgba(139, 92, 246, 0.14);
border: 1px solid rgba(139, 92, 246, 0.4);
transform: translateZ(calc(var(--depth, 0) * var(--pop) * 1px));
}
.badge--warn {
color: var(--warn);
background: rgba(251, 191, 36, 0.13);
border-color: rgba(251, 191, 36, 0.4);
}
.badge--ok {
color: var(--ok);
background: rgba(52, 211, 153, 0.13);
border-color: rgba(52, 211, 153, 0.4);
}
.art {
margin: 16px 0 18px;
height: 150px;
border-radius: var(--r-md);
display: grid;
place-items: center;
border: 1px solid var(--line);
transform: translateZ(calc(var(--depth, 0) * var(--pop) * 1px));
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.35);
}
.art--aurora {
background:
radial-gradient(120px 80px at 30% 20%, rgba(139, 92, 246, 0.55), transparent 70%),
linear-gradient(135deg, #2a1a4a, #101326);
}
.art--nimbus {
background:
radial-gradient(120px 80px at 70% 25%, rgba(34, 211, 238, 0.5), transparent 70%),
linear-gradient(135deg, #0d2b33, #101326);
}
.art--meridian {
background:
radial-gradient(120px 80px at 50% 20%, rgba(52, 211, 153, 0.45), transparent 70%),
linear-gradient(135deg, #10322a, #101326);
}
.art__glyph {
font-size: 56px;
line-height: 1;
color: rgba(255, 255, 255, 0.9);
text-shadow: 0 6px 18px rgba(0, 0, 0, 0.5);
}
.meta {
transform-style: preserve-3d;
}
.meta__title {
margin: 0;
font-size: 20px;
font-weight: 700;
letter-spacing: -0.01em;
transform: translateZ(calc(var(--depth, 0) * var(--pop) * 1px));
}
.meta__sub {
margin: 4px 0 14px;
font-size: 13px;
color: var(--muted);
transform: translateZ(calc(var(--depth, 0) * var(--pop) * 1px));
}
.meta__row {
display: flex;
align-items: center;
justify-content: space-between;
transform: translateZ(calc(var(--depth, 0) * var(--pop) * 1px));
}
.price {
font-size: 18px;
font-weight: 700;
}
.rating {
font-size: 13px;
font-weight: 600;
color: var(--warn);
}
/* ---------- Readout ---------- */
.readout {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 14px;
margin-top: clamp(32px, 5vw, 48px);
padding: 14px 18px;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-md);
font-size: 13px;
}
.readout__key {
color: var(--muted);
}
.readout__key::before {
content: "";
}
.readout__val {
font-weight: 600;
padding-right: 6px;
}
.readout__val.mono {
font-family: "JetBrains Mono", monospace;
color: var(--accent-2);
}
/* ---------- Responsive ---------- */
@media (max-width: 520px) {
.deck {
grid-template-columns: 1fr;
perspective: 800px;
}
input[type="range"] {
width: 110px;
}
.art {
height: 130px;
}
}
@media (prefers-reduced-motion: reduce) {
.tilt-card__inner {
transform: none !important;
transition: none;
}
.tilt-card__glare {
opacity: 0 !important;
}
.badge,
.art,
.meta__title,
.meta__sub,
.meta__row {
transform: none !important;
}
}// 3D Tilt Card with Glare
// Pointer Events drive rotateX/rotateY via CSS custom properties.
// requestAnimationFrame batches updates; a spring-back transition handles leave.
(() => {
"use strict";
const cards = Array.from(document.querySelectorAll("[data-tilt]"));
const intensityInput = document.getElementById("intensity");
const intensityVal = document.getElementById("intensity-val");
const glareToggle = document.getElementById("glare");
const parallaxToggle = document.getElementById("parallax");
const roCard = document.getElementById("ro-card");
const roX = document.getElementById("ro-x");
const roY = document.getElementById("ro-y");
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)");
// Shared, live-tunable settings.
const settings = {
max: Number(intensityInput.value),
glare: glareToggle.checked,
parallax: parallaxToggle.checked,
};
// Per-card pending frame state so we only touch the DOM inside rAF.
const frames = new WeakMap();
function applyPop(card) {
// How far inner layers pop toward the viewer (0 = flat).
card.style.setProperty("--pop", settings.parallax ? "1" : "0");
}
function setDepths(card) {
// Seed each layer's depth from its data-depth attribute (once).
card.querySelectorAll("[data-depth]").forEach((el) => {
el.style.setProperty("--depth", el.dataset.depth || "0");
});
}
function updateReadout(card, rx, ry) {
const label = card.getAttribute("aria-label") || "Card";
roCard.textContent = label.replace(/ product card$/i, "");
roX.textContent = `${rx.toFixed(1)}°`;
roY.textContent = `${ry.toFixed(1)}°`;
}
function schedule(card, px, py, rect) {
if (frames.get(card)) return; // already queued this frame
const id = requestAnimationFrame(() => {
frames.set(card, 0);
render(card, px, py, rect);
});
frames.set(card, id);
}
function render(card, px, py, rect) {
// Normalize pointer position to -0.5..0.5 across the card.
const nx = (px - rect.left) / rect.width - 0.5;
const ny = (py - rect.top) / rect.height - 0.5;
// Tilt toward the pointer: vertical pointer -> rotateX, horizontal -> rotateY.
const rotX = (-ny * settings.max).toFixed(2);
const rotY = (nx * settings.max).toFixed(2);
card.style.setProperty("--rx", `${rotX}deg`);
card.style.setProperty("--ry", `${rotY}deg`);
// Glare tracks the pointer as a percentage.
if (settings.glare) {
card.style.setProperty("--mx", `${((px - rect.left) / rect.width) * 100}%`);
card.style.setProperty("--my", `${((py - rect.top) / rect.height) * 100}%`);
card.style.setProperty("--glare", "1");
} else {
card.style.setProperty("--glare", "0");
}
updateReadout(card, Number(rotX), Number(rotY));
}
function reset(card) {
card.classList.remove("is-active");
card.style.setProperty("--rx", "0deg");
card.style.setProperty("--ry", "0deg");
card.style.setProperty("--glare", "0");
// Center the glare so its fade-out looks natural.
card.style.setProperty("--mx", "50%");
card.style.setProperty("--my", "50%");
}
function bind(card) {
setDepths(card);
applyPop(card);
let rect = null;
const onEnter = (e) => {
if (reduceMotion.matches) return;
rect = card.getBoundingClientRect();
card.classList.add("is-active");
schedule(card, e.clientX, e.clientY, rect);
};
const onMove = (e) => {
if (reduceMotion.matches) return;
if (!rect) rect = card.getBoundingClientRect();
schedule(card, e.clientX, e.clientY, rect);
};
const onLeave = () => {
rect = null;
reset(card);
};
// Pointer Events cover mouse, touch, and pen with one code path.
card.addEventListener("pointerenter", onEnter);
card.addEventListener("pointermove", onMove);
card.addEventListener("pointerleave", onLeave);
card.addEventListener("pointercancel", onLeave);
// Keyboard focus gives a gentle preset tilt so it's clearly interactive.
card.addEventListener("focus", () => {
if (reduceMotion.matches) return;
card.classList.add("is-active");
const t = (settings.max * 0.5).toFixed(2);
card.style.setProperty("--rx", `${t}deg`);
card.style.setProperty("--ry", `${-t}deg`);
if (settings.glare) card.style.setProperty("--glare", "1");
updateReadout(card, Number(t), Number(-t));
});
card.addEventListener("blur", onLeave);
}
// Controls
intensityInput.addEventListener("input", () => {
settings.max = Number(intensityInput.value);
intensityVal.textContent = `${settings.max}°`;
});
glareToggle.addEventListener("change", () => {
settings.glare = glareToggle.checked;
if (!settings.glare) cards.forEach((c) => c.style.setProperty("--glare", "0"));
});
parallaxToggle.addEventListener("change", () => {
settings.parallax = parallaxToggle.checked;
cards.forEach(applyPop);
});
cards.forEach(bind);
// Honor a runtime change in the motion preference.
reduceMotion.addEventListener("change", () => {
if (reduceMotion.matches) cards.forEach(reset);
});
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>3D Tilt Card with Glare</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap"
rel="stylesheet"
/>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<main class="page">
<header class="masthead">
<p class="eyebrow">UI Components</p>
<h1>3D Tilt Card with Glare</h1>
<p class="lede">
Move your pointer over a card. It leans toward the cursor in 3D, a specular
glare sweeps across the surface, and inner layers float forward for real
parallax depth. Everything springs back on leave.
</p>
<div class="controls" role="group" aria-label="Tilt controls">
<label class="control">
<span class="control__label">Max tilt</span>
<input
id="intensity"
type="range"
min="0"
max="24"
step="1"
value="14"
aria-describedby="intensity-val"
/>
<output id="intensity-val" class="control__val">14°</output>
</label>
<label class="control control--switch">
<input id="glare" type="checkbox" checked />
<span class="control__label">Glare</span>
</label>
<label class="control control--switch">
<input id="parallax" type="checkbox" checked />
<span class="control__label">Parallax</span>
</label>
</div>
</header>
<section class="deck" aria-label="Tilt cards">
<!-- Card 1 -->
<article class="tilt-card" tabindex="0" data-tilt data-accent="violet"
aria-label="Aurora Keyboard product card">
<div class="tilt-card__inner">
<div class="tilt-card__glare" aria-hidden="true"></div>
<div class="tilt-card__content">
<span class="badge" data-depth="60">In stock</span>
<div class="art art--aurora" data-depth="40" aria-hidden="true">
<span class="art__glyph">⌘</span>
</div>
<div class="meta">
<h2 class="meta__title" data-depth="30">Aurora 65</h2>
<p class="meta__sub" data-depth="22">Hot-swap mechanical keyboard</p>
<div class="meta__row" data-depth="16">
<span class="price">$149</span>
<span class="rating" aria-label="Rated 4.9 out of 5">★ 4.9</span>
</div>
</div>
</div>
</div>
</article>
<!-- Card 2 -->
<article class="tilt-card" tabindex="0" data-tilt data-accent="cyan"
aria-label="Nimbus Headphones product card">
<div class="tilt-card__inner">
<div class="tilt-card__glare" aria-hidden="true"></div>
<div class="tilt-card__content">
<span class="badge badge--warn" data-depth="60">Low stock</span>
<div class="art art--nimbus" data-depth="40" aria-hidden="true">
<span class="art__glyph">◐</span>
</div>
<div class="meta">
<h2 class="meta__title" data-depth="30">Nimbus Pro</h2>
<p class="meta__sub" data-depth="22">Active-noise wireless headphones</p>
<div class="meta__row" data-depth="16">
<span class="price">$219</span>
<span class="rating" aria-label="Rated 4.7 out of 5">★ 4.7</span>
</div>
</div>
</div>
</div>
</article>
<!-- Card 3 -->
<article class="tilt-card" tabindex="0" data-tilt data-accent="emerald"
aria-label="Meridian Watch product card">
<div class="tilt-card__inner">
<div class="tilt-card__glare" aria-hidden="true"></div>
<div class="tilt-card__content">
<span class="badge badge--ok" data-depth="60">New</span>
<div class="art art--meridian" data-depth="40" aria-hidden="true">
<span class="art__glyph">◷</span>
</div>
<div class="meta">
<h2 class="meta__title" data-depth="30">Meridian S</h2>
<p class="meta__sub" data-depth="22">Titanium smartwatch, 9-day battery</p>
<div class="meta__row" data-depth="16">
<span class="price">$329</span>
<span class="rating" aria-label="Rated 5.0 out of 5">★ 5.0</span>
</div>
</div>
</div>
</div>
</article>
</section>
<footer class="readout" aria-live="polite">
<span class="readout__key">Active card</span>
<span id="ro-card" class="readout__val">—</span>
<span class="readout__key">rotateX</span>
<span id="ro-x" class="readout__val mono">0.0°</span>
<span class="readout__key">rotateY</span>
<span id="ro-y" class="readout__val mono">0.0°</span>
</footer>
</main>
<script src="script.js"></script>
</body>
</html>A grid of interactive product cards that respond to pointer movement with realistic 3D perspective. As the cursor moves across a card, JavaScript maps its position to rotateX/rotateY values and writes them to CSS custom properties, so the card visually leans toward the pointer. A radial glare highlight tracks the same coordinates to simulate light glancing off a glossy surface, while inner layers (badge, artwork, title) are pushed forward along the Z axis with translateZ to create genuine parallax depth.
The interaction is built on the Pointer Events API, so a single set of handlers covers mouse, trackpad, touch and pen input. On pointer leave the card animates back to its resting state with a spring-like transition, and the glare fades out. A live readout shows the current rotation angles, and a control lets you tune the maximum tilt intensity in real time. Everything is keyboard-focusable with a visible focus ring, and prefers-reduced-motion is honored by disabling the tilt for users who opt out.
Under the hood the demo leans entirely on the web platform: CSS perspective and transform-style: preserve-3d for the 3D scene, custom properties as the bridge between JS and CSS, and requestAnimationFrame to keep pointer updates smooth and jank-free.