---
name: 3d-product-card
description: Use when you want "Premium, commercial, showy" - Presents a product in a tilted or layered 3D-style card.
---

# 3D Product Card

> **Category:** Card / Layout  -  **Personality:** Premium, commercial, showy
>
> **Best use:** Product pages, launch sites
>
> **Ratings:** Professional 4/5 - Casual 4/5 - Exotic 4/5 - Visual impact 5/5 - Difficulty 4/5 - Performance cost 3/5

## What it is

A single, oversized **hero product card** that presents one item like a launch announcement. Unlike a tilting *gallery* of cards, this is one showpiece: the card is a real 3D scene (`perspective` + `preserve-3d`) whose contents live on different `translateZ` planes — a glow disc and a giant ghost wordmark sit deep, the product plate pops forward, and floating badges hover in front of everything. Moving the pointer rotates the whole card so the layers parallax against each other and the product appears to lift off the page. Use it for a product page, a drop/launch hero, or any "buy this one thing" moment that needs commercial weight.

## Dependencies / CDN

**None** — pure CSS 3D transforms for the effect, vanilla JS for the pointer tilt and the shop interactions. The demo only loads two display/body fonts (optional):

```html
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<!-- Archivo at width 125 = the wide "expanded" cut for the brand/price; Manrope for UI -->
<link href="https://fonts.googleapis.com/css2?family=Archivo:wdth,wght@125,600;125,700;125,800&family=Manrope:wght@400;500;600;700;800&display=swap" rel="stylesheet">
```

## HTML

The stage owns the `perspective`; the card is the thing that tilts; everything inside is a depth layer. Trimmed to the structural essentials:

```html
<div class="pc-stage">                       <!-- holds perspective + warm-lit backdrop -->
  <article class="pc-card">                  <!-- the surface that rotates -->
    <span class="pc-glare"></span>           <!-- cursor-tracked sheen -->

    <div class="pc-copy">                     <!-- left: lifted toward the viewer -->
      <h2 class="pc-name">Pulse Nitro <em>Racer</em></h2>
      <div class="pc-buyline"><span class="pc-price">$165</span> <span class="pc-was">$220</span></div>
      <div class="pc-swatches">…colour buttons…</div>
      <div class="pc-sizes">…size buttons…</div>
      <button class="pc-add"><span id="pc-addlabel">Add to bag · $165</span></button>
    </div>

    <div class="pc-visual">                    <!-- right: the stacked 3D layers -->
      <span class="pc-ghost">NITRO</span>      <!-- deepest: outlined wordmark -->
      <span class="pc-disc"></span>            <!-- glow stage behind the product -->
      <span class="pc-shadow"></span>          <!-- contact shadow -->
      <div class="pc-plate">                   <!-- the product, popped forward -->
        <img src="/…/assets/img/product-sneaker.jpg" alt="…">
      </div>
      <div class="pc-badge"><b>-25%</b><span>LAUNCH</span></div>  <!-- floats in front -->
    </div>
  </article>
</div>
```

## CSS

```css
/* 1) STAGE owns the perspective + an overflow-hidden frame (no page scroll) */
.pc-stage{position:relative; overflow:hidden; border-radius:28px; isolation:isolate;
  display:flex; align-items:center; justify-content:center; padding:74px 30px 64px;
  perspective:1300px; perspective-origin:55% 38%;
  background:radial-gradient(132% 122% at 76% 2%,#3a2410,#20150d 38%,#100b08 72%,#0a0807);}

/* 2) THE CARD — the only element that rotates. preserve-3d keeps children in 3D. */
.pc-card{position:relative; max-width:980px; display:grid; grid-template-columns:1.02fr .98fr;
  gap:30px; align-items:center; padding:40px 42px; border-radius:26px;
  transform-style:preserve-3d; backface-visibility:hidden; will-change:transform;
  transform:rotateX(calc(var(--rx,0)*1deg)) rotateY(calc(var(--ry,0)*1deg)) scale(var(--s,1));
  background:linear-gradient(158deg,#fcfcfe,#eef0f5 60%,#e6e8ee);
  box-shadow:0 42px 84px -36px rgba(8,6,5,.92),
             /* a coloured glow that SLIDES with the tilt = ambient light */
             calc(var(--ry,0)*-1.5px) calc(var(--rx,0)*1.7px) 72px -30px rgba(255,90,31,.5),
             inset 0 1px 0 rgba(255,255,255,.95);}

/* 3) DEPTH — each layer gets its own translateZ; bigger Z = closer to you */
.pc-copy  {transform-style:preserve-3d; transform:translateZ(32px);}  /* copy lifts slightly */
.pc-visual{position:relative; align-self:stretch; transform-style:preserve-3d;}
.pc-ghost {position:absolute; left:50%; top:50%; transform:translate(-50%,-152%) translateZ(18px);
  font-weight:800; font-size:clamp(54px,7.6vw,92px); color:transparent;
  -webkit-text-stroke:2px rgba(22,24,31,.09);}                        /* deep ghost word */
.pc-disc  {position:absolute; left:50%; top:50%; width:340px; height:340px; border-radius:50%;
  transform:translate(-50%,-50%) translateZ(6px);
  background:radial-gradient(circle at 50% 42%,rgba(255,255,255,.96),rgba(255,138,61,.2) 46%,transparent 72%);}
.pc-plate {position:absolute; left:50%; top:50%; width:96%; max-width:380px; aspect-ratio:1/1;
  border-radius:24px; overflow:hidden;
  transform:translate(-50%,-52%) translateZ(72px);                    /* the product pops out */
  box-shadow:0 30px 52px -26px rgba(8,6,5,.72);}
.pc-badge {position:absolute; top:-2%; right:-3%; transform:translateZ(124px);}  /* nearest layer */

/* 4) GLARE — a soft highlight that follows the cursor across the card surface */
.pc-glare{position:absolute; inset:0; border-radius:inherit; pointer-events:none;
  transform:translateZ(2px); opacity:var(--g,0); transition:opacity .3s;
  background:radial-gradient(440px circle at calc(var(--mx,50)*1%) calc(var(--my,50)*1%),
             rgba(255,255,255,.6), transparent 56%);}

@media (prefers-reduced-motion:reduce){ .pc-plate{animation:none} }
```

## JavaScript

The effect itself = set six CSS variables on the card from the pointer, then **lerp** the current values toward the target each frame so the card glides and settles instead of snapping. The loop is skipped entirely under reduced-motion (the card stays flat). Shop interactions (swatches, size pick, add-to-bag) are ordinary click handlers and run regardless.

```js
(function(){
  var stage = document.querySelector('.pc-stage'),
      card  = document.querySelector('.pc-card');
  if(!stage || !card) return;
  if(window.matchMedia && matchMedia('(prefers-reduced-motion: reduce)').matches) return; // flat

  var cur={rx:0,ry:0,mx:50,my:50,g:0,s:1}, tgt=Object.assign({},cur), raf=0,
      keys=['rx','ry','mx','my','g','s'], MAX=9, EASE=0.16;

  function write(){ keys.forEach(function(k){ card.style.setProperty('--'+k, cur[k].toFixed(2)); }); }
  function loop(){
    var settled=true;
    keys.forEach(function(k){ var d=tgt[k]-cur[k]; cur[k]+=d*EASE; if(Math.abs(d)>0.02) settled=false; });
    write();
    raf = settled ? 0 : requestAnimationFrame(loop);
  }
  function kick(){ if(!raf) raf=requestAnimationFrame(loop); }

  stage.addEventListener('pointermove', function(e){
    if(e.pointerType==='touch') return;
    var r=stage.getBoundingClientRect();
    var px=(e.clientX-r.left)/r.width, py=(e.clientY-r.top)/r.height;
    tgt.ry=(px-0.5)*2*-MAX;   // turn toward the cursor
    tgt.rx=(py-0.5)*2* MAX;   // tip the near edge toward the cursor
    tgt.mx=px*100; tgt.my=py*100; tgt.g=1; tgt.s=1.015;
    kick();
  });
  function rest(){ tgt.rx=0; tgt.ry=0; tgt.mx=50; tgt.my=50; tgt.g=0; tgt.s=1; kick(); }
  stage.addEventListener('pointerleave', rest);
  stage.addEventListener('pointercancel', rest);
})();
```

## How it works

- **One perspective, one rotation.** `perspective` lives on `.pc-stage`; only `.pc-card` rotates. Because the card is `transform-style: preserve-3d`, its children keep their own positions in that 3D space instead of being flattened.
- **`translateZ` is the whole illusion.** Each layer is pushed toward the viewer by a different amount (disc `6px` → product `72px` → badge `124px`). When the card tilts, near layers shift more than far layers across the screen — that *parallax* is what makes the product look like it is floating above the card.
- **Lerp, don't snap.** Pointer events only update a *target*; a `requestAnimationFrame` loop eases the live value `cur += (tgt-cur)*0.16` each frame and stops itself once everything has settled (`raf=0`). One rAF loop, no timers.
- **The glare reads the same coordinates** (`--mx/--my`) as a radial-gradient centre, so the highlight tracks the cursor. A second, *tilt-driven* coloured `box-shadow` (`calc(var(--ry)*…)`) slides opposite the rotation to fake a fixed light source.

## Customization

- **Drama:** raise the `translateZ` spread (e.g. plate `72px` → `100px`) and/or lower `perspective` (1300 → 900) for a punchier pop. Keep `MAX` tilt around 8-12deg; past ~16deg it feels like it will fall over.
- **Theme it to the product.** The accent is one variable chain (`--pc-acc / --pc-acc2 / --pc-acc-deep`); the demo's swatch buttons rewrite them at runtime to recolour the glow, badge and CTA. Swap the backdrop gradient for a different mood (dark warm room here; a light studio works too).
- **Layers:** add or remove planes freely — a reflection, a second badge, a "ships today" chip. Just give each a distinct `translateZ`.
- **Feel:** `EASE` 0.16 is springy-but-controlled; lower (0.08) = heavier/laggier, higher (0.3) = snappier.

## Accessibility & performance

- Only `transform` and `opacity` animate — both GPU-friendly. The rAF loop self-terminates when idle, so nothing runs while the pointer is still. `will-change:transform` is set on the moving card only.
- **Reduced motion:** the tilt loop returns early (card stays flat) and the idle float/pulse animations are disabled, but every shop control still works.
- Touch pointers are ignored for tilt (no hover on touch) while taps still buy; the layout collapses to a single column under 840px with the depth flattened.
- Controls are real `<button>`s: swatches use `role="radio"`/`aria-checked`, sizes use `aria-pressed`, the sold-out size is `disabled`, the product `<img>` has descriptive `alt`, and purely decorative layers (ghost word, disc, shadow, badge) are `aria-hidden`.

## Gotchas

- **Perspective goes on the PARENT, not the rotating element.** Put `perspective` on `.pc-card` itself and the children won't share a vanishing point — the 3D collapses.
- **A missing `transform-style: preserve-3d` flattens everything.** It must be on the card *and* on any intermediate wrapper (`.pc-copy`, `.pc-visual`) whose children use `translateZ`, or those grandchildren snap back to the surface.
- **`overflow:hidden` on an ancestor kills `translateZ`.** Layers that pop "out" need room — clip at the outer `.pc-stage` (to stop page scrollbars) but keep the card and its layer wrappers `overflow:visible`.
- **Never break out with `width:100vw` or negative margins** to make the product escape the frame — that causes horizontal scroll. Let elements overflow the *card* while the `.pc-stage` (rounded + `overflow:hidden`) contains them.
- A product photo on a near-white background blends into a light card; keep it on a rounded **plate** with its own subtle fill (and a contact shadow) so it still reads as a distinct floating object.
