Hover-tilt cards

Hover-tilt cards

bold

Cards 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 */
})();
Hover-tilt cards · CreativePower