---
name: perspective-hover
description: Use when you want "Modern, dynamic, polished" - Applies perspective transforms on hover to give elements a 3D angle.
---

# Perspective Hover

> **Category:** Glass / Depth  -  **Personality:** Modern, dynamic, polished
>
> **Best use:** Cards, buttons, screenshots
>
> **Ratings:** Professional 4/5 - Casual 4/5 - Exotic 3/5 - Visual impact 4/5 - Difficulty 3/5 - Performance cost 2/5

## What it is

Perspective hover tilts an element in 3D toward the cursor: as the pointer moves across a card, the card yaws and pitches (`rotateX` / `rotateY`) inside a `perspective` viewport so it feels like a physical object you can angle. Add a glare that tracks the pointer and lift the inner content onto its own `translateZ` plane and the card gains real, layered depth — the "tilt card" pattern popularised by vanilla-tilt. It reads as modern, premium and tactile, which is why product cards, hero screenshots and primary buttons use it to invite interaction.

## Dependencies / CDN

None - pure CSS for the 3D, plus ~30 lines of vanilla JS to feed the pointer position into CSS custom properties. The demo loads display/body/mono fonts (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&family=JetBrains+Mono:wght@500&family=Sora:wght@600;700;800&display=swap" rel="stylesheet">
```

## HTML

The pointer-driven element is the `.card`; its parent `.scene` owns the `perspective`. The photo + glare live in a clipped `.frame`, while the text sits in `.layer`s that float forward on hover:

```html
<div class="scene">
  <article class="card" data-tiltcard data-max="13">
    <div class="frame">
      <img class="photo" src="/img/product-headphones.jpg" alt="Eclipse Over-Ear headphones">
      <div class="scrim" aria-hidden="true"></div>
      <div class="glare" aria-hidden="true"></div>
    </div>
    <div class="layer foot">
      <h3>Eclipse Over-Ear</h3>
      <span class="price">$329</span>
    </div>
  </article>
</div>
```

## CSS

```css
/* 1) The PARENT establishes the 3D viewport (lower px = stronger perspective) */
.scene{ perspective: 1100px; }

/* 2) The card reads pointer values from custom props; it must preserve-3d so layers get depth */
.card{
  --rx:0deg; --ry:0deg; --px:0; --py:0; --mx:50%; --my:50%;
  position:relative; transform-style:preserve-3d; will-change:transform;
  transform: rotateX(var(--rx)) rotateY(var(--ry));
  transition: transform .6s cubic-bezier(.23,1,.32,1), box-shadow .45s ease; /* smooth spring-back */
}
.card.is-live{ transition: transform .1s ease-out, box-shadow .45s ease; }   /* snappy while tracking */

/* 3) Clip the photo in a SEPARATE frame — overflow:hidden flattens 3D, so it can't live on the card */
.frame{ position:absolute; inset:0; border-radius:inherit; overflow:hidden; }
.photo{ position:absolute; inset:-7%; width:114%; height:114%; object-fit:cover;
  transform: translate(calc(var(--px)*16px), calc(var(--py)*16px)); }          /* bg parallax */

/* 4) Pointer-tracking specular glare (screen blend = bright highlight) */
.glare{ position:absolute; inset:0; pointer-events:none; opacity:0; mix-blend-mode:screen;
  transition:opacity .4s ease;
  background: radial-gradient(26% 32% at var(--mx) var(--my), rgba(255,255,255,.45), transparent 72%); }
.card.is-live .glare{ opacity:1; }

/* 5) Content layers — flat at rest, lifted onto a +Z plane on hover (foreground counter-parallax) */
.layer{ transition: transform .6s cubic-bezier(.23,1,.32,1); }
.foot{ transform: translate(calc(var(--px)*-18px), calc(var(--py)*-18px)); }
.card.is-live .foot{ transform: translateZ(72px) translate(calc(var(--px)*-18px), calc(var(--py)*-18px)); }

/* 6) Honour reduced motion: no tilt, no parallax, no transitions */
@media (prefers-reduced-motion: reduce){
  .card, .layer, .photo, .glare{ transition:none!important; transform:none!important; }
}
```

## JavaScript

```js
(function(){
  // Only enable on hover-capable, fine pointers; never under reduced motion.
  var fine   = !window.matchMedia || matchMedia('(hover: hover) and (pointer: fine)').matches;
  var reduce = window.matchMedia && matchMedia('(prefers-reduced-motion: reduce)').matches;
  if (!fine || reduce) return;
  var clamp = function(v){ return v < -0.5 ? -0.5 : (v > 0.5 ? 0.5 : v); };

  document.querySelectorAll('[data-tiltcard]').forEach(function(card){
    var max = parseFloat(card.getAttribute('data-max')) || 12, rect = null;

    card.addEventListener('pointerenter', function(){
      rect = card.getBoundingClientRect();        // cache once → no per-move layout reflow
      card.classList.add('is-live');
    });
    card.addEventListener('pointermove', function(e){
      if (!rect) rect = card.getBoundingClientRect();
      var px = clamp((e.clientX - rect.left) / rect.width  - 0.5),   // -0.5 … 0.5
          py = clamp((e.clientY - rect.top ) / rect.height - 0.5),
          s  = card.style;
      s.setProperty('--ry', (px * max).toFixed(2) + 'deg');   // yaw follows X
      s.setProperty('--rx', (-py * max).toFixed(2) + 'deg');  // pitch is inverted
      s.setProperty('--px', px.toFixed(3));                   // parallax + glare feed
      s.setProperty('--py', py.toFixed(3));
      s.setProperty('--mx', ((px + 0.5) * 100).toFixed(1) + '%');
      s.setProperty('--my', ((py + 0.5) * 100).toFixed(1) + '%');
    });
    card.addEventListener('pointerleave', function(){
      var s = card.style; card.classList.remove('is-live');
      s.setProperty('--rx','0deg'); s.setProperty('--ry','0deg');
      s.setProperty('--px','0');    s.setProperty('--py','0');
      s.setProperty('--mx','50%');  s.setProperty('--my','50%');     // recenters glare
    });
  });
})();
```

## How it works

- **`perspective` on the parent** turns flat `rotateX/Y` degrees into apparent depth. A smaller value (600–900px) exaggerates the angle; a larger one (1100–1600px) is subtle and premium. Each tilt element gets its own `.scene` so the vanishing point is centred on that element.
- **JS only writes CSS variables** — `--rx/--ry` (degrees), `--px/--py` (normalised −0.5…0.5), `--mx/--my` (glare %). All the actual rendering is declarative CSS, so motion runs on the compositor.
- **`transform-style: preserve-3d`** keeps the card's children in the same 3D space, so a `translateZ()` on a `.layer` genuinely floats it above the photo. Lifting the title/price onto a +Z plane while the photo drifts the *opposite* way is what sells the parallax depth.
- **Two transition speeds** give the premium feel: a snappy `.1s` while `.is-live` (the tilt tracks the cursor instantly) and a slow eased `.6s` on `pointerleave` (the card springs back to flat).
- **The glare** is a radial gradient positioned at `var(--mx) var(--my)` with `mix-blend-mode: screen`, fading in on hover — a moving specular highlight that reads as light catching an angled surface.

## Customization

- **Tilt amount:** `data-max` per element — 10–13deg for big cards, 14–18deg for small buttons (small elements need more degrees to read).
- **Perspective strength:** lower `perspective` (e.g. `700px`) = dramatic/exotic; higher (`1400px`) = restrained/corporate.
- **Depth stack:** give each `.layer` a different `translateZ` (e.g. 50 / 72 / 94px) so badge, title and price separate on hover.
- **Glare:** swap `screen` for `overlay` on coloured/solid surfaces (buttons), or drop the glare entirely for a flatter look.
- **Feel:** shorten the spring-back transition for a crisp toy-like snap, or lengthen it (`.8s`) for a heavier, more luxurious return.

## Accessibility & performance

- **Reduced motion:** the demo gates the whole effect twice — JS bails on `prefers-reduced-motion: reduce`, and CSS forces `transform:none` so nothing tilts even if scripts run.
- **Pointer gate:** initialised only behind `(hover: hover) and (pointer: fine)`, so touch devices get a clean static card instead of janky tilt on every drag.
- **Cheap to animate:** only `transform` (and `opacity` on the glare) change — both GPU-composited, no layout/paint. The pointer rect is cached on `pointerenter` so `pointermove` never triggers a reflow. Hence the 2/5 performance cost.
- **Content stays accessible:** the tilt is decorative; headings, prices and `<button>`s are real DOM with real text, and the glare/scrim/photo overlays are `aria-hidden`. Keyboard focus and screen-reader order are unaffected because nothing is hidden or reordered.

## Gotchas

- **`overflow:hidden` flattens 3D.** Any value other than `overflow:visible` creates a flattening context that kills `preserve-3d` for descendants — so you can't clip the photo's rounded corners on the same element that holds the floating layers. Put the image in a *separate* clipped `.frame` and keep the `.layer`s as siblings (as the demo does).
- **`perspective` must be on the parent, not the card.** Putting it on the rotating element itself collapses the effect. Use the parent `.scene`, or the shorthand `transform: perspective(1100px) rotateX()...` on the card alone (but then nested layers won't share depth).
- **Resting distortion:** apply `translateZ` only in the `.is-live` state. A permanent +Z scales the layer up via perspective even at rest, making text look slightly oversized/blurry.
- **Glare must respect the radius:** the glare/`::overlay` element needs `border-radius:inherit` (and the frame `overflow:hidden`) or the highlight shows square corners poking past a rounded card/pill.
- **Bounding-rect feedback:** recomputing `getBoundingClientRect()` on every `pointermove` while the element is mid-rotation can make the tilt jitter; cache it on enter.
- **Will-change budget:** `will-change: transform` helps a handful of cards but is not free — don't slap it on dozens of elements or you'll spend more memory than you save.
