15 CSS 3D Tilt Hover Cards01 / 15

Vanilla JSMIT licensed

E-Commerce Product Spotlight Card

A premium product card where the sneaker cutout floats 50px in front of the card face, the Add-to-Cart button rides even higher, and a radial glare chases the cursor. The depth stack — background, product, copy, CTA — is the exact pattern D2C stores use to make a $189 product feel worth it before a single scroll.

Published

Live Demo
Try it

The code

<section class="thc-01" aria-label="E-commerce product spotlight tilt card demo">
  <article class="thc-01__card" tabindex="0" aria-label="Aurora Runner sneaker, 189 dollars">
    <span class="thc-01__badge" aria-hidden="true">NEW DROP</span>
    <div class="thc-01__product" aria-hidden="true"></div>
    <h3 class="thc-01__name">Aurora Runner</h3>
    <p class="thc-01__meta">Limited colourway &middot; <strong>$189</strong></p>
    <button class="thc-01__cta" type="button">Add to Cart</button>
    <div class="thc-01__glare" aria-hidden="true"></div>
  </article>
</section>
.thc-01,
.thc-01 *,
.thc-01 *::before,
.thc-01 *::after {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}

.thc-01 {
  --ink: oklch(0.93 0.01 260);
  --accent: oklch(0.72 0.17 40);
  font-family: 'Segoe UI',system-ui,sans-serif;
  width: 100%;
  min-height: 100vh;
  display: grid;
  place-items: center;
  padding: 48px 24px;
  background: radial-gradient(80% 90% at 15% 10%,oklch(0.28 0.06 290 / .9),transparent 60%),radial-gradient(70% 80% at 90% 95%,oklch(0.25 0.07 40 / .8),transparent 60%),oklch(0.15 0.02 280);
}

.thc-01 .thc-01__card {
  position: relative;
  width: min(300px,100%);
  padding: 28px 26px 26px;
  border-radius: 22px;
  background: linear-gradient(160deg,oklch(0.24 0.03 285),oklch(0.18 0.025 280));
  border: 1px solid rgba(255,255,255,.1);
  transform: perspective(1100px) rotateX(var(--rx,0deg)) rotateY(var(--ry,0deg));
  transform-style: preserve-3d;
  transition: transform .6s cubic-bezier(.2,.9,.3,1.15);
  box-shadow: 0 30px 60px -30px rgba(0,0,0,.8);
  cursor: pointer;
}

.thc-01 .thc-01__card.is-live {
  transition: transform .08s linear;
}

.thc-01 .thc-01__card:focus-visible {
  outline: 3px solid var(--accent);
  outline-offset: 4px;
}

.thc-01 .thc-01__badge {
  position: absolute;
  top: 18px;
  left: 22px;
  font: 700 10px/1 'Segoe UI',sans-serif;
  letter-spacing: .12em;
  color: var(--accent);
  border: 1px solid color-mix(in oklab,var(--accent) 50%,transparent);
  border-radius: 999px;
  padding: 6px 10px;
  transform: translateZ(35px);
}

.thc-01 .thc-01__product {
  width: 82%;
  aspect-ratio: 4/3;
  margin: 26px auto 8px;
  border-radius: 16px;
  background: radial-gradient(70% 90% at 35% 30%,oklch(0.75 0.16 40),oklch(0.55 0.19 25) 55%,oklch(0.35 0.1 300) 100%);
  transform: translateZ(50px) rotate(-6deg);
  filter: drop-shadow(0 24px 22px rgba(0,0,0,.55));
  transition: transform .6s cubic-bezier(.2,.9,.3,1.15);
}

.thc-01 .thc-01__card.is-live .thc-01__product {
  transform: translateZ(62px) rotate(-8deg);
}

.thc-01 .thc-01__name {
  font-size: 20px;
  font-weight: 700;
  color: var(--ink);
  transform: translateZ(25px);
}

.thc-01 .thc-01__meta {
  font-size: 13px;
  color: oklch(0.7 0.02 270);
  margin-top: 4px;
  transform: translateZ(25px);
}

.thc-01 .thc-01__meta strong {
  color: var(--ink);
}

.thc-01 .thc-01__cta {
  margin-top: 16px;
  width: 100%;
  height: 44px;
  border: none;
  border-radius: 12px;
  font: 600 14px 'Segoe UI',sans-serif;
  color: oklch(0.15 0.02 280);
  background: linear-gradient(120deg,oklch(0.78 0.15 45),oklch(0.7 0.18 30));
  transform: translateZ(60px);
  cursor: pointer;
  box-shadow: 0 14px 26px -12px oklch(0.6 0.18 35 / .7);
}

.thc-01 .thc-01__cta:focus-visible {
  outline: 3px solid var(--ink);
  outline-offset: 3px;
}

.thc-01 .thc-01__glare {
  position: absolute;
  inset: 0;
  border-radius: inherit;
  pointer-events: none;
  background: radial-gradient(260px circle at var(--mx,50%) var(--my,50%),rgba(255,255,255,.22),transparent 65%);
  opacity: 0;
  transition: opacity .4s ease;
  transform: translateZ(70px);
}

.thc-01 .thc-01__card.is-live .thc-01__glare {
  opacity: 1;
}

@media (prefers-reduced-motion: reduce) {
  .thc-01 .thc-01__card,
    .thc-01 .thc-01__product {
    transition: none;
  }
}
(() => {
  const card = document.querySelector('.thc-01 .thc-01__card');
  if (!card) return;
  // Touch + motion guards: static (fully styled) card for everyone else.
  if (!matchMedia('(hover:hover) and (pointer:fine)').matches) return;
  if (matchMedia('(prefers-reduced-motion: reduce)').matches) return;
  const MAX = 14; // degrees of tilt at the card's edge
  card.addEventListener('pointermove', (e) => {
    const r = card.getBoundingClientRect();
    const px = (e.clientX - r.left) / r.width;   // 0 → 1 across the card
    const py = (e.clientY - r.top) / r.height;
    card.style.setProperty('--rx', ((0.5 - py) * MAX).toFixed(2) + 'deg');
    card.style.setProperty('--ry', ((px - 0.5) * MAX).toFixed(2) + 'deg');
    card.style.setProperty('--mx', (px * 100).toFixed(1) + '%');
    card.style.setProperty('--my', (py * 100).toFixed(1) + '%');
    card.classList.add('is-live');
  });
  card.addEventListener('pointerleave', () => {
    card.classList.remove('is-live');           // re-arm the slow spring transition
    card.style.setProperty('--rx', '0deg');
    card.style.setProperty('--ry', '0deg');
  });
})();
Paste this into ChatGPT, Claude, Cursor, or any coding assistant. The block below is pre-framed with everything the AI needs to integrate this demo into your project — markup, styles, scoping notes, and the source URL. Hit Copy and paste straight into your chat.
Here's a working CSS 3D Tilt Hover Card from CodeFronts. Use it as-is or adapt to your framework. All classes are scoped under a unique prefix so the code won't collide with your existing styles. MIT licensed.
Demo: E-Commerce Product Spotlight Card
Source: https://codefronts.com/components/css-3d-tilt-hover-cards/e-commerce-product-spotlight-card/

A premium product card where the sneaker cutout floats 50px in front of the card face, the Add-to-Cart button rides even higher, and a radial glare chases the cursor. The depth stack — background, product, copy, CTA — is the exact pattern D2C stores use to make a $189 product feel worth it before a single scroll.
## HTML
```html
<section class="thc-01" aria-label="E-commerce product spotlight tilt card demo">
  <article class="thc-01__card" tabindex="0" aria-label="Aurora Runner sneaker, 189 dollars">
    <span class="thc-01__badge" aria-hidden="true">NEW DROP</span>
    <div class="thc-01__product" aria-hidden="true"></div>
    <h3 class="thc-01__name">Aurora Runner</h3>
    <p class="thc-01__meta">Limited colourway &middot; <strong>$189</strong></p>
    <button class="thc-01__cta" type="button">Add to Cart</button>
    <div class="thc-01__glare" aria-hidden="true"></div>
  </article>
</section>
```
## CSS
```css
.thc-01,
.thc-01 *,
.thc-01 *::before,
.thc-01 *::after {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}

.thc-01 {
  --ink: oklch(0.93 0.01 260);
  --accent: oklch(0.72 0.17 40);
  font-family: 'Segoe UI',system-ui,sans-serif;
  width: 100%;
  min-height: 100vh;
  display: grid;
  place-items: center;
  padding: 48px 24px;
  background: radial-gradient(80% 90% at 15% 10%,oklch(0.28 0.06 290 / .9),transparent 60%),radial-gradient(70% 80% at 90% 95%,oklch(0.25 0.07 40 / .8),transparent 60%),oklch(0.15 0.02 280);
}

.thc-01 .thc-01__card {
  position: relative;
  width: min(300px,100%);
  padding: 28px 26px 26px;
  border-radius: 22px;
  background: linear-gradient(160deg,oklch(0.24 0.03 285),oklch(0.18 0.025 280));
  border: 1px solid rgba(255,255,255,.1);
  transform: perspective(1100px) rotateX(var(--rx,0deg)) rotateY(var(--ry,0deg));
  transform-style: preserve-3d;
  transition: transform .6s cubic-bezier(.2,.9,.3,1.15);
  box-shadow: 0 30px 60px -30px rgba(0,0,0,.8);
  cursor: pointer;
}

.thc-01 .thc-01__card.is-live {
  transition: transform .08s linear;
}

.thc-01 .thc-01__card:focus-visible {
  outline: 3px solid var(--accent);
  outline-offset: 4px;
}

.thc-01 .thc-01__badge {
  position: absolute;
  top: 18px;
  left: 22px;
  font: 700 10px/1 'Segoe UI',sans-serif;
  letter-spacing: .12em;
  color: var(--accent);
  border: 1px solid color-mix(in oklab,var(--accent) 50%,transparent);
  border-radius: 999px;
  padding: 6px 10px;
  transform: translateZ(35px);
}

.thc-01 .thc-01__product {
  width: 82%;
  aspect-ratio: 4/3;
  margin: 26px auto 8px;
  border-radius: 16px;
  background: radial-gradient(70% 90% at 35% 30%,oklch(0.75 0.16 40),oklch(0.55 0.19 25) 55%,oklch(0.35 0.1 300) 100%);
  transform: translateZ(50px) rotate(-6deg);
  filter: drop-shadow(0 24px 22px rgba(0,0,0,.55));
  transition: transform .6s cubic-bezier(.2,.9,.3,1.15);
}

.thc-01 .thc-01__card.is-live .thc-01__product {
  transform: translateZ(62px) rotate(-8deg);
}

.thc-01 .thc-01__name {
  font-size: 20px;
  font-weight: 700;
  color: var(--ink);
  transform: translateZ(25px);
}

.thc-01 .thc-01__meta {
  font-size: 13px;
  color: oklch(0.7 0.02 270);
  margin-top: 4px;
  transform: translateZ(25px);
}

.thc-01 .thc-01__meta strong {
  color: var(--ink);
}

.thc-01 .thc-01__cta {
  margin-top: 16px;
  width: 100%;
  height: 44px;
  border: none;
  border-radius: 12px;
  font: 600 14px 'Segoe UI',sans-serif;
  color: oklch(0.15 0.02 280);
  background: linear-gradient(120deg,oklch(0.78 0.15 45),oklch(0.7 0.18 30));
  transform: translateZ(60px);
  cursor: pointer;
  box-shadow: 0 14px 26px -12px oklch(0.6 0.18 35 / .7);
}

.thc-01 .thc-01__cta:focus-visible {
  outline: 3px solid var(--ink);
  outline-offset: 3px;
}

.thc-01 .thc-01__glare {
  position: absolute;
  inset: 0;
  border-radius: inherit;
  pointer-events: none;
  background: radial-gradient(260px circle at var(--mx,50%) var(--my,50%),rgba(255,255,255,.22),transparent 65%);
  opacity: 0;
  transition: opacity .4s ease;
  transform: translateZ(70px);
}

.thc-01 .thc-01__card.is-live .thc-01__glare {
  opacity: 1;
}

@media (prefers-reduced-motion: reduce) {
  .thc-01 .thc-01__card,
    .thc-01 .thc-01__product {
    transition: none;
  }
}
```

## JavaScript
```js
(() => {
  const card = document.querySelector('.thc-01 .thc-01__card');
  if (!card) return;
  // Touch + motion guards: static (fully styled) card for everyone else.
  if (!matchMedia('(hover:hover) and (pointer:fine)').matches) return;
  if (matchMedia('(prefers-reduced-motion: reduce)').matches) return;
  const MAX = 14; // degrees of tilt at the card's edge
  card.addEventListener('pointermove', (e) => {
    const r = card.getBoundingClientRect();
    const px = (e.clientX - r.left) / r.width;   // 0 → 1 across the card
    const py = (e.clientY - r.top) / r.height;
    card.style.setProperty('--rx', ((0.5 - py) * MAX).toFixed(2) + 'deg');
    card.style.setProperty('--ry', ((px - 0.5) * MAX).toFixed(2) + 'deg');
    card.style.setProperty('--mx', (px * 100).toFixed(1) + '%');
    card.style.setProperty('--my', (py * 100).toFixed(1) + '%');
    card.classList.add('is-live');
  });
  card.addEventListener('pointerleave', () => {
    card.classList.remove('is-live');           // re-arm the slow spring transition
    card.style.setProperty('--rx', '0deg');
    card.style.setProperty('--ry', '0deg');
  });
})();
```

How this works

JavaScript does one job: convert the pointer's position over the card into four custom properties — --rx/--ry (rotation, ±14°) and --mx/--my (glare position, 0–100%). The card's transform is pure CSS: perspective(1100px) rotateX(var(--rx)) rotateY(var(--ry)), and because transform-style:preserve-3d cascades, each child adds its own translateZ() — product cutout at 50px, copy at 25px, CTA at 60px — so layers genuinely separate as the card turns.

The glare is a radial-gradient positioned at var(--mx) var(--my) on a pointer-events-none overlay, faded in only while the class is-live is present. Movement runs on an 80ms linear transition (responsive but never jittery); on pointerleave the class drops and a 600ms springy cubic-bezier glides everything back to rest.

Make it yours

  • Tune tilt strength with the MAX constant in the JS (10–12° reads premium; 20°+ reads playful).
  • Swap the gradient product placeholder for a transparent PNG cutout — drop-shadow() already draws its floating shadow.
  • Push depth harder by raising the translateZ values together (keep CTA above product so it stays tappable-looking).
  • Re-stage the scene by rotating the two oklch hues in the backdrop.

Gotchas — read before shipping

  • The glare overlay MUST be pointer-events:none or it eats the hover and the tilt stutters.
  • preserve-3d is needed on every ancestor between the perspective element and the translateZ children — one flat layer collapses the whole stack.
  • Use pointer events + getBoundingClientRect() per move; caching the rect breaks after any scroll or resize.

Browser support

ChromeSafariFirefoxEdge
111+16.4+113+111+

oklch() is the newest piece; the tilt itself (perspective, preserve-3d, translateZ) works back to 2015 engines.

Techniques used in this demo

Search CodeFronts

Loading…