---
name: 3d-card-tilt
description: Use when you want "Interactive, premium, playful" - Tilts cards in response to cursor movement or device orientation.
---

# 3D Card Tilt

> **Category:** Glass / Depth  -  **Personality:** Interactive, premium, playful
>
> **Best use:** Product cards, portfolios, galleries
>
> **Ratings:** Professional 4/5 - Casual 4/5 - Exotic 4/5 - Visual impact 4/5 - Difficulty 3/5 - Performance cost 3/5

## What it is

3D card tilt rotates a card toward the cursor on two axes (`rotateX` / `rotateY`) so the surface appears to "look at" the pointer. Inner elements are pushed forward on the Z axis (`translateZ`) so they parallax independently, and a soft glare highlight tracks the cursor to fake a glossy reflection. The result feels tactile and premium — ideal for product cards, portfolio thumbnails and feature tiles where you want a single hero interaction. The motion is eased frame-by-frame so it settles with a little weight rather than snapping.

## Dependencies / CDN

None - pure CSS for the 3D, vanilla JS for the pointer math (no libraries). The demo loads two Google Fonts (display + body), which are optional:

```html
<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=Hanken+Grotesk:wght@400;500;600;700&family=Unbounded:wght@500;600;700&display=swap" rel="stylesheet">
```

## HTML

The wrapper owns the `perspective`; the card is the element that rotates; children are the parallax layers.

```html
<div class="tile">                          <!-- holds the perspective / vanishing point -->
  <article class="card">                    <!-- the element that tilts -->
    <span class="glare" aria-hidden="true"></span>
    <span class="tag">Editor&rsquo;s pick</span>          <!-- floats forward -->
    <button class="fav" aria-label="Save" aria-pressed="false">&#9829;</button>
    <div class="media"><img src="/path/headphones.jpg" alt="Halo ANC headphones"></div>
    <div class="body">
      <p class="cat">Audio</p>
      <h3 class="name">Halo ANC Studio</h3>
      <div class="row"><span class="price">$349</span><button class="buy">Add</button></div>
    </div>
  </article>
</div>
```

## CSS

```css
/* 1) Perspective lives on the PARENT, not the tilted element */
.tile{ perspective: 950px; }

/* 2) The card reads CSS variables the JS writes (unitless numbers + calc) */
.card{
  position: relative; border-radius: 20px; padding: 14px;
  transform-style: preserve-3d;            /* children keep their own depth */
  backface-visibility: hidden;
  will-change: transform;
  transform:
    rotateX(calc(var(--rx,0) * 1deg))
    rotateY(calc(var(--ry,0) * 1deg))
    scale(var(--s,1));
  box-shadow:
    0 24px 50px -26px rgba(0,0,0,.85),
    calc(var(--ry,0) * -1.1px) calc(var(--rx,0) * 1.3px) 46px -22px rgba(124,92,255,.45); /* shadow leans with the tilt */
}

/* 3) Parallax layers: bigger translateZ = floats higher above the card face */
.media{ transform: translateZ(28px); border-radius: 13px; overflow: hidden; aspect-ratio: 4/3; }
.body { transform: translateZ(22px); transform-style: preserve-3d; }
.tag  { position:absolute; top:24px; left:24px;  transform: translateZ(64px); }
.fav  { position:absolute; top:22px; right:22px; transform: translateZ(80px); }

/* 4) Glare: a radial highlight whose centre is the cursor (--mx/--my in %) */
.glare{
  position:absolute; inset:0; border-radius:inherit; pointer-events:none;
  transform: translateZ(40px); mix-blend-mode: screen; opacity: var(--g,0);
  background: radial-gradient(circle at calc(var(--mx,50)*1%) calc(var(--my,50)*1%),
    rgba(255,255,255,.5), rgba(255,214,170,.16) 26%, transparent 56%);
}
```

## JavaScript

Map the pointer position (0–1 across the card) to rotation + glare, then ease the live values toward the target each animation frame so it feels weighted. **Guard `prefers-reduced-motion`** before starting any loop.

```js
(function(){
  var MAX = 11, EASE = 0.18;
  if (matchMedia('(prefers-reduced-motion: reduce)').matches) return; // stay flat

  document.querySelectorAll('.card').forEach(function(card){
    var cur = {rx:0,ry:0,mx:50,my:50,g:0,s:1};
    var tgt = {rx:0,ry:0,mx:50,my:50,g:0,s:1};
    var raf = 0, keys = ['rx','ry','mx','my','g','s'];

    function write(){
      card.style.setProperty('--rx', cur.rx.toFixed(2));
      card.style.setProperty('--ry', cur.ry.toFixed(2));
      card.style.setProperty('--mx', cur.mx.toFixed(1));
      card.style.setProperty('--my', cur.my.toFixed(1));
      card.style.setProperty('--g',  cur.g.toFixed(3));
      card.style.setProperty('--s',  cur.s.toFixed(3));
    }
    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();
      if (settled){ raf = 0; keys.forEach(function(k){ cur[k]=tgt[k]; }); write(); }
      else raf = requestAnimationFrame(loop);
    }
    function kick(){ if(!raf) raf = requestAnimationFrame(loop); }

    card.addEventListener('pointermove', function(e){
      var r = card.getBoundingClientRect();
      var px = (e.clientX-r.left)/r.width, py = (e.clientY-r.top)/r.height;
      tgt.ry = (px-0.5)*2 * -MAX;   // right edge tips toward the cursor
      tgt.rx = (py-0.5)*2 *  MAX;   // top edge tips toward the cursor
      tgt.mx = px*100; tgt.my = py*100;
      tgt.g = 1; tgt.s = 1.03;
      kick();
    });
    function rest(){ tgt={rx:0,ry:0,mx:50,my:50,g:0,s:1}; kick(); }
    card.addEventListener('pointerleave', rest);
    card.addEventListener('pointercancel', rest);
  });
})();
```

## How it works

- **`perspective` on the parent** sets how strong the 3D foreshortening is — lower = more dramatic. Putting it on each `.tile` (not the shared grid) gives every card its own centred vanishing point, so an outer card doesn't look skewed.
- **`rotateX` / `rotateY` from the pointer.** The cursor's position is normalised to 0–1; subtracting `0.5` makes it `-0.5…+0.5`, then `×2×MAX` maps it to `±MAX` degrees. Signs are chosen so the edge nearest the cursor tips *toward* you (the card "faces" the pointer).
- **`translateZ` parallax.** Because the card is `transform-style: preserve-3d`, children at different Z depths shift by different amounts as it rotates — the tag/heart pop, the image floats, the text trails. This is what sells the depth.
- **Eased settle.** Rather than writing the pointer value straight to the DOM, each frame nudges the current value 18% toward the target (a lerp). Fast moves get a slight trailing weight; on `pointerleave` everything eases back to flat. The loop stops itself once settled to save battery.
- **Cursor glare.** A `radial-gradient` whose centre is `var(--mx) var(--my)` (the cursor %) blended with `mix-blend-mode: screen` reads as a moving specular highlight.

## Customization

- **Tilt strength:** change `MAX` (8–10° subtle, 14–18° dramatic). Pair with `perspective` — tighter perspective + high MAX gets intense fast.
- **Weight / responsiveness:** raise `EASE` toward `1` for instant 1:1 tracking, lower it (`0.1`) for a heavier, laggier feel.
- **Parallax pop:** scale the `translateZ` values together; keep the floating UI (tag/heart) highest and the body lowest.
- **Glare:** swap the highlight colour, drop `mix-blend-mode` for a flatter sheen, or remove it entirely for a matte card.
- **Device orientation:** on touch devices you can drive `--rx/--ry` from `window.addEventListener('deviceorientation', …)` (`event.gamma`/`event.beta`) instead of the pointer — note iOS needs `DeviceOrientationEvent.requestPermission()` from a user gesture.

## Accessibility & performance

- **Respect reduced motion:** the script returns before attaching anything when `prefers-reduced-motion: reduce` is set, so the cards render as clean static panels. Don't put essential information behind the tilt.
- **Animate only `transform` / `opacity`** (both compositor-friendly); the shadow is the one paint-heavy property, so keep the number of simultaneously-tilting cards reasonable and add `will-change: transform`.
- The rAF loop **stops itself once the card has settled**, so idle cards cost nothing.
- Keep interactive children (the heart, the buy button) as real `<button>`s with labels — the tilt is pointer-only decoration and must not be the only way to use the card. `pointermove` still bubbles from those buttons, so the tilt keeps working over them while their own clicks fire (call `stopPropagation` on the button if needed).

## Gotchas

- **Perspective goes on the parent, not the rotated element.** Put `perspective` on `.card` itself and the 3D collapses.
- **A flat ancestor kills `translateZ`.** Every element between the perspective root and a `translateZ` child must carry `transform-style: preserve-3d` — one default-`flat` wrapper (e.g. a flex `.row`) silently flattens everything inside it.
- **`overflow: hidden` clips popped layers.** Keep it off the tilting card (use it only on the inner image frame) or your forward `translateZ` elements get sliced at the edges.
- **Unitless vars + `calc`.** Store `--rx` as a number and write `rotateX(calc(var(--rx) * 1deg))`; that lets the *same* variable also drive the `box-shadow` offset in `px`.
- **Don't write the DOM on every `pointermove`** without throttling/rAF — batch into one `requestAnimationFrame` (as above) or you thrash style/layout on fast mice.
- `mix-blend-mode` / `opacity` belong on the **glare child**, not the card — applying them to the `preserve-3d` element flattens its 3D context.
