Hover-tilt cards
boldCards tilt in 3D toward the cursor with a glow spotlight.
Each card rotates on X+Y axes based on cursor position, with a soft accent-colored spotlight that follows the cursor. The 'iPad app preview' aesthetic. Pure vanilla JS — no library.
animatedinteractive3dcardhovervanilla-js
━━ Use this pattern
━━ Live preview
━━ HTML markup
<section class="ht-stage">
<div class="ht-grid">
<div class="ht-card">
<div class="ht-icon">01</div>
<h3>Author DESIGN.md</h3>
<p>Brief, wizard, or template — Claude generates a brand spec.</p>
</div>
<div class="ht-card">
<div class="ht-icon">02</div>
<h3>Generate pages</h3>
<p>Landing pages stream in real time, brand-coherent.</p>
</div>
<div class="ht-card">
<div class="ht-icon">03</div>
<h3>Export and deploy</h3>
<p>Static, Next.js, or React. Deploy anywhere in seconds.</p>
</div>
</div>
</section>━━ CSS
.ht-stage {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 4rem 6%;
/* stageMode "adaptive": opaque panels on the page surface; the spotlight is a tint of
the accent rather than additive light, so this reads either polarity. */
--ht-surface: var(--color-surface, #08090b);
/* Two raised steps for the card's own gradient, per the spec's raised-surface rule. */
--ht-card-hi: color-mix(in oklab, var(--color-surface, #13141a) 91%, var(--color-on-surface, #13141a));
--ht-card-lo: color-mix(in oklab, var(--color-surface, #0d0e12) 94%, var(--color-on-surface, #0d0e12));
--ht-line: color-mix(in oklab, var(--color-surface, #1a1c20) 88%, var(--color-on-surface, #1a1c20));
--ht-ink: var(--color-on-surface, #fafafa);
--ht-ink-dim: color-mix(in oklab, var(--color-on-surface, #9a9da4) 62%, var(--color-surface, #9a9da4));
/* The accent mixes toward the surface BEFORE the spotlight's alpha, so a dark or cool
primary doesn't wash out weaker than the hand-tuned literal assumed. */
--ht-accent: color-mix(in oklab, var(--color-primary, #ff6b1a) 80%, var(--color-surface, #ff6b1a));
background: var(--ht-surface);
perspective: 1200px;
}
.ht-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1.5rem;
max-width: 1000px;
}
.ht-card {
position: relative;
padding: 2rem 1.5rem;
background: linear-gradient(135deg, var(--ht-card-hi), var(--ht-card-lo));
border: 1px solid var(--ht-line);
border-radius: 16px;
color: var(--ht-ink);
cursor: pointer;
transform-style: preserve-3d;
transition: transform 220ms ease-out;
will-change: transform;
}
.ht-card::before {
content: '';
position: absolute; inset: 0;
border-radius: 16px;
background: radial-gradient(circle at var(--mx, 50%) var(--my, 50%), color-mix(in oklab, var(--ht-accent) 28%, transparent), transparent 55%);
opacity: 0;
transition: opacity 250ms ease;
pointer-events: none;
}
.ht-card:hover::before/* cp:demo-start */, .ht-card.ht-demo-active::before/* cp:demo-end */ { opacity: 1; }
.ht-card .ht-icon {
width: 36px; height: 36px;
border-radius: 8px;
background: color-mix(in oklab, var(--ht-accent) 15%, transparent);
border: 1px solid color-mix(in oklab, var(--ht-accent) 30%, transparent);
margin-bottom: 1rem;
display: flex;
align-items: center;
justify-content: center;
font-family: "Geist Mono", monospace;
font-size: 0.85rem;
color: var(--ht-accent);
}
.ht-card h3 {
font-family: "Geist", system-ui, sans-serif;
font-size: 1.25rem;
font-weight: 700;
margin-bottom: 0.5rem;
letter-spacing: -0.015em;
}
.ht-card p { color: var(--ht-ink-dim); font-size: 0.9rem; line-height: 1.55; }━━ JavaScript
(function() {
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
var cards = Array.prototype.slice.call(document.querySelectorAll('.ht-card'));
if (!cards.length) return;
var MAX = 16; // degrees — stronger than the original 10
var LIFT = 24; // px translateZ
/* cp:demo-start */
var userActive = false;
/* cp:demo-end */
function setTilt(card, rx, ry, mx, my) {
card.style.transform = 'perspective(1200px) rotateX(' + rx + 'deg) rotateY(' + ry + 'deg) translateZ(' + LIFT + 'px) scale(1.03)';
card.style.setProperty('--mx', mx + '%');
card.style.setProperty('--my', my + '%');
}
function resetTilt(card) {
card.style.transform = 'perspective(1200px) rotateX(0) rotateY(0) translateZ(0) scale(1)';
}
cards.forEach(function(card) {
/* cp:demo-start */
card.addEventListener('mouseenter', function(){ userActive = true; stopDemo(); card.classList.remove('ht-demo-active'); });
/* cp:demo-end */
card.addEventListener('mousemove', function(e) {
/* cp:demo-start */
userActive = true;
/* cp:demo-end */
var r = card.getBoundingClientRect();
var x = e.clientX - r.left, y = e.clientY - r.top;
var rx = ((y - r.height / 2) / r.height) * -MAX;
var ry = ((x - r.width / 2) / r.width) * MAX;
setTilt(card, rx, ry, (x / r.width * 100), (y / r.height * 100));
});
card.addEventListener('mouseleave', function() { resetTilt(card); });
});
// Showcase auto-demo: gently cycle a tilt across the cards so the 3D effect is
// visible without the user moving the mouse. Real pointer interaction cancels it.
/* cp:demo-start */
var demoTimer = null;
/* cp:demo-end */
/* cp:demo-start */
function stopDemo() { if (demoTimer) { clearInterval(demoTimer); demoTimer = null; } cards.forEach(function(c){ c.classList.remove('ht-demo-active'); }); }
/* cp:demo-end */
/* cp:demo-start */
function startDemo() {
if (!window.__CP_DEMO__ || userActive) return;
var i = 0;
function step() {
if (userActive) { stopDemo(); return; }
if (window.__cpPaused) return;
cards.forEach(function(c){ resetTilt(c); c.classList.remove('ht-demo-active'); });
var card = cards[i % cards.length];
card.classList.add('ht-demo-active');
// sweep the tilt direction so it looks like a cursor arcing across the card
var phase = (Date.now() / 1000) % 2;
var rx = Math.sin(phase * Math.PI) * (MAX * 0.7);
var ry = Math.cos(phase * Math.PI) * (MAX * 0.7);
setTilt(card, rx, ry, 50 + ry * 2, 50 - rx * 2);
i++;
}
demoTimer = setInterval(step, 1400);
step();
}
/* cp:demo-end */
// small delay so the page settles, then begin the idle demo
/* cp:demo-start */
setTimeout(startDemo, 800);
/* cp:demo-end */
})();