3D Tilt Card with Glare

Three product cards lean toward the pointer up to ±12° with quickTo-smoothed rotation, layered translateZ depth on image, title and badge, a specular glare that follows the pointer and a soft shadow that slides the opposite way; they relax flat on leave.

Loading

AI prompt

Recreate this effect with any AI tool

Paste into Claude, Cursor, v0, Lovable or Bolt. 1486 words of exact values — rewritten live as you tune the Playground.

prompt.md · 3D Tilt Card with Glare
### EFFECT: 3D Tilt Card with Glare
**What it does (visual):** Three product cards sit in a row on a dark page under a small serif headline. Each card is a rounded rectangle with a gradient product image, a pill badge in the top-left corner of the image, a serif product name, a one-line description and a row of outlined mono chips. When the pointer enters a card, the card begins to lean toward the pointer in 3D: pointing at the top edge tips the top toward you, pointing at the right edge tips the right side toward you, up to ±12° on each axis. The motion is smoothed (every pointer move eases to its new angle over 0.5s with a decelerating curve) so the card feels like it has mass. Inside the card the layers sit at different depths: the image floats 30px in front of the card face, the title 50px, the badge 60px, so as the card turns they slide against each other with a subtle parallax. A soft white highlight (a large, very soft radial spot) appears at the pointer position at 0.35 opacity and follows it, like a light source reflecting off glossy stock. Underneath, a blurred dark shadow slides up to 24px in the *opposite* direction to the raised corner — tilt the right side toward you and the shadow drifts left — which sells the lift. When the pointer leaves, the card eases back to flat over 0.8s with the `power3.out` curve, the glare fades out over 0.5s and the shadow returns to centre. On touch devices nothing tracks; instead each card slowly rocks ±3°/±4° in a 3.2s sine loop, offset by 0.6s per card, with the glare drifting across.

**Tech stack:**
- GSAP 3.13+ core (`quickTo`, `matchMedia`). No plugins.
- CDN: `<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>`
- npm: `npm i gsap` → `import gsap from "gsap";`
- Fonts: variable serif for the title and product names (demo uses Fraunces 300), a sans for descriptions, a mono for badge/chips.

**HTML structure:**
```html
<section class="tcg">
  <header class="tcg__head">
    <p class="tcg__eyebrow">Hover · 3D tilt + glare</p>
    <h1 class="tcg__title">Objects that lean toward you.</h1>
  </header>
  <div class="tcg__grid">
    <article class="tcg__item">
      <div class="tcg__shadow" aria-hidden="true"></div>
      <div class="tcg__card" tabindex="0" aria-label="Nocturne — closed-back studio headphones">
        <div class="tcg__media"><div class="tcg__art tcg__art--1"></div></div>
        <span class="tcg__badge">New</span>
        <div class="tcg__body">
          <h2 class="tcg__name">Nocturne</h2>
          <p class="tcg__desc">Closed-back studio headphones, tuned flat.</p>
          <ul class="tcg__chips"><li>Audio</li><li>2026</li><li>€389</li></ul>
        </div>
        <div class="tcg__glare" aria-hidden="true"><div class="tcg__glare-spot"></div></div>
      </div>
    </article>
    <!-- two more .tcg__item (Meridian, Lumen) -->
  </div>
</section>
```

**CSS requirements:**
- `.tcg__grid`: `display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: clamp(1rem, 2.6vw, 2rem); max-width: 1100px; margin: 0 auto;`
- `.tcg__item`: `position: relative; perspective: 1000px;` (perspective on the wrapper, one per card).
- `.tcg__card`: `position: relative; display: flex; flex-direction: column; gap: 1rem; padding: 1.1rem; border-radius: 20px; background: var(--surface, #121212); border: 1px solid var(--line, rgba(255,255,255,.08)); transform-style: preserve-3d; will-change: transform;` — **never** `overflow: hidden` here (it would flatten the 3D context).
- `.tcg__media`: `position: relative; aspect-ratio: 4/3; border-radius: 12px; overflow: hidden; transform: translateZ(30px);` — `.tcg__art` inside is `position: absolute; inset: -6%; width: 112%; height: 112%;` (oversized so the depth parallax never shows an edge) painted with two radial gradients over a 160° linear gradient (e.g. `#5a3fd1` + `#d14f8f` over `#15123a → #0a0a12`), plus an `::after` grain layer (inline SVG `feTurbulence` data-URI, `mix-blend-mode: overlay; opacity: .55`).
- `.tcg__badge`: `position: absolute; top: 1.75rem; left: 1.75rem; padding: .35em .8em; border-radius: 999px; background: rgba(10,10,10,.72); color: #fff; font: 10px mono uppercase; letter-spacing: .12em; transform: translateZ(60px);` — `.tcg__badge--accent { background: var(--accent, #d4ff3a); color: #0A0A0A; }` for the highlighted card.
- `.tcg__body`: `transform: translateZ(20px);` and `.tcg__name`: serif 300, `font-size: clamp(1.6rem, 2.4vw, 2.1rem); letter-spacing: -0.03em; transform: translateZ(30px);` (total 50px in front of the face). Chips: `font-size: 10px; border: 1px solid var(--line); border-radius: 999px; padding: .4em .7em; color: var(--muted);`
- `.tcg__glare`: `position: absolute; inset: 0; border-radius: inherit; overflow: hidden; pointer-events: none; transform: translateZ(70px);`
- `.tcg__glare-spot`: `position: absolute; left: -50%; top: -50%; width: 200%; height: 200%; background: radial-gradient(circle at center, rgba(255,255,255,.95) 0%, rgba(255,255,255,.35) 18%, rgba(255,255,255,0) 50%); mix-blend-mode: soft-light; opacity: 0; will-change: transform, opacity;` — moving it by `xPercent/yPercent` is pure transform.
- `.tcg__shadow`: `position: absolute; inset: 10% 6% -4%; border-radius: 28px; background: rgba(0,0,0,.7); filter: blur(26px); opacity: .55; transform: translateY(14px); will-change: transform;` — it sits behind the card in DOM order.
- Light theme (`[data-theme="light"]`): shadow `rgba(20,20,20,.35)`, glare `mix-blend-mode: overlay`.

**Animation spec (exact values):**
1. Per card, create smoothers: `const q = (t, p) => gsap.quickTo(t, p, { duration: 0.5, ease: 'power2.out' })` → `rotX = q(card, 'rotationX')`, `rotY = q(card, 'rotationY')`, `spotX = q(spot, 'xPercent')`, `spotY = q(spot, 'yPercent')`, `shX = q(shadow, 'x')`, `shY = q(shadow, 'y')`.
2. `pointerenter`: kill any running leave timeline; cache `bounds = card.getBoundingClientRect()`; re-seed every quickTo with the live value so it does not jump: `rotX(gsap.getProperty(card, 'rotationX'), gsap.getProperty(card, 'rotationX'))` (same for rotY, shX, shY); `gsap.to(spot, { opacity: 0.35, duration: 0.4, ease: 'power2.out', overwrite: true })`.
3. `pointermove`: `px = clamp((clientX - bounds.left) / bounds.width, 0, 1)`, `py` likewise. Then
   - `rotX((py - 0.5) * 2 * 12)` → range −12° … +12° (top edge toward the viewer when `py = 0`).
   - `rotY((0.5 - px) * 2 * 12)` → right edge toward the viewer when `px = 1`.
   - `spotX((px - 0.5) * 50)`, `spotY((py - 0.5) * 50)` — the spot is 200% of the card, so 50% of its own width equals one card width; its centre lands exactly under the pointer.
   - `shX(-(px - 0.5) * 2 * 24)`, `shY(14 - (py - 0.5) * 2 * 24)` — shadow slides up to 24px against the tilt (14px is its resting vertical offset).
4. `pointerleave`: pause the four position quickTo tweens (`fn.tween.pause()`), then one timeline:
   - `.to(card, { rotationX: 0, rotationY: 0, duration: 0.8, ease: 'power3.out' }, 0)`
   - `.to(shadow, { x: 0, y: 14, duration: 0.8, ease: 'power3.out' }, 0)`
   - `.to(spot, { opacity: 0, duration: 0.5, ease: 'power2.out' }, 0)`
5. Entrance (decorative): `gsap.from('.tcg__eyebrow, .tcg__title', { y: 24, opacity: 0, duration: 1, ease: 'power3.out', stagger: 0.08 })` and `gsap.from('.tcg__item', { y: 40, opacity: 0, duration: 1.1, ease: 'power3.out', stagger: 0.08, delay: 0.15 })`.
6. Depth is static CSS (`translateZ` 20/30/50/60/70px); the parallax comes for free from the card's rotation under `perspective: 1000px`.

**Interaction:** Hover anywhere on a card → tilt follows with 0.5s `power2.out` smoothing; glare fades in 0.4s to 0.35. Leave → 0.8s `power3.out` settle to flat, glare out in 0.5s. Keyboard focus (`tabindex="0"`) runs the enter handler (glare on, lift ready) and blur runs the leave handler; `:focus-visible` shows a 2px accent ring (`box-shadow: 0 0 0 2px var(--bg), 0 0 0 4px var(--accent, #d4ff3a)`).

**Responsive behavior:** `gsap.matchMedia()` with two branches: `(hover: hover) and (pointer: fine)` → the live tilt above. `(hover: none), (pointer: coarse)` → no pointer tracking; each card gets `gsap.timeline({ repeat: -1, yoyo: true, delay: i * 0.6, defaults: { duration: 3.2, ease: 'sine.inOut' } })` with `.fromTo(card, { rotationX: 3, rotationY: -4 }, { rotationX: -3, rotationY: 4 }, 0)` (both scaled by `12 / 12`), `.fromTo(spot, { xPercent: -14, yPercent: -10, opacity: .18 }, { xPercent: 14, yPercent: 10, opacity: .26 }, 0)` and `.fromTo(shadow, { x: 8, y: 20 }, { x: -8, y: 8 }, 0)`. Under 768px the grid becomes a single column (`max-width: 420px`).

**Accessibility:** `prefers-reduced-motion: reduce` → no rotation at all (the move handler returns after computing the pointer position), no idle float, no entrance tween; the glare rests at a static `opacity: .18` so the cards still read as glossy. Cards are focusable with a visible ring and carry an `aria-label` summarising the product; the shadow and glare are `aria-hidden`.

**Performance rules:** Only `transform` and `opacity` are animated (`rotationX/Y`, `xPercent/yPercent`, `x/y`). `quickTo` reuses one tween per property, so pointer moves allocate nothing. `will-change: transform` is limited to the card, the shadow and the glare spot. `getBoundingClientRect` is read once on enter, not per move. Everything is created inside `gsap.context()` and reverted on unmount; the `matchMedia` branches clean themselves up on breakpoint change.

**Customization knobs:**
- `MAX_TILT` (12) — degrees per axis; 8 is restrained, 18 is playful.
- `SHADOW_SHIFT` (24) — how far the shadow counter-slides.
- `GLARE_OPACITY` (0.35) — highlight strength; the gradient stops in `.tcg__glare-spot` control its softness.
- `translateZ` values (20/30/50/60/70px) — depth separation.
- quickTo `duration` (0.5) — follow lag; the leave `duration` (0.8) and ease (`power3.out`) — settle time and character (`elastic.out(1, 0.5)` wobbles back).
- Accent (`#d4ff3a`) — the highlighted badge and focus ring.

**Acceptance criteria:**
- [ ] Cards are flat at rest; the pointer side tips toward the viewer, never beyond ±12°.
- [ ] Image, title and badge visibly parallax against each other while tilting.
- [ ] The highlight is centred under the pointer and fades 0→0.35; the shadow moves opposite to the raised corner.
- [ ] Leaving returns to flat in ~0.8s with no snap; re-entering mid-return does not jump.
- [ ] Touch devices show the slow idle float; reduced-motion users see static cards with a faint glare.
- [ ] No layout shift, no console errors.

Source

Full source code

<section class="tcg">
  <header class="tcg__head">
    <p class="tcg__eyebrow">Hover · 3D tilt + glare</p>
    <h1 class="tcg__title">Objects that lean toward you.</h1>
  </header>

  <div class="tcg__grid">
    <article class="tcg__item">
      <div class="tcg__shadow" aria-hidden="true"></div>
      <div class="tcg__card" tabindex="0" aria-label="Nocturne — closed-back studio headphones">
        <div class="tcg__media"><div class="tcg__art tcg__art--1"></div></div>
        <span class="tcg__badge">New</span>
        <div class="tcg__body">
          <h2 class="tcg__name">Nocturne</h2>
          <p class="tcg__desc">Closed-back studio headphones, tuned flat.</p>
          <ul class="tcg__chips" role="list"><li>Audio</li><li>2026</li><li>€389</li></ul>
        </div>
        <div class="tcg__glare" aria-hidden="true"><div class="tcg__glare-spot"></div></div>
      </div>
    </article>

    <article class="tcg__item">
      <div class="tcg__shadow" aria-hidden="true"></div>
      <div class="tcg__card" tabindex="0" aria-label="Meridian — solar field watch">
        <div class="tcg__media"><div class="tcg__art tcg__art--2"></div></div>
        <span class="tcg__badge tcg__badge--accent">Limited</span>
        <div class="tcg__body">
          <h2 class="tcg__name">Meridian</h2>
          <p class="tcg__desc">Solar field watch with a sapphire dome.</p>
          <ul class="tcg__chips" role="list"><li>Wear</li><li>2026</li><li>€640</li></ul>
        </div>
        <div class="tcg__glare" aria-hidden="true"><div class="tcg__glare-spot"></div></div>
      </div>
    </article>

    <article class="tcg__item">
      <div class="tcg__shadow" aria-hidden="true"></div>
      <div class="tcg__card" tabindex="0" aria-label="Lumen — portable reading lamp">
        <div class="tcg__media"><div class="tcg__art tcg__art--3"></div></div>
        <span class="tcg__badge">Restock</span>
        <div class="tcg__body">
          <h2 class="tcg__name">Lumen</h2>
          <p class="tcg__desc">Portable reading lamp, 2700K, forty hours.</p>
          <ul class="tcg__chips" role="list"><li>Light</li><li>2025</li><li>€180</li></ul>
        </div>
        <div class="tcg__glare" aria-hidden="true"><div class="tcg__glare-spot"></div></div>
      </div>
    </article>
  </div>

  <p class="tcg__hint">Move across a card · on touch the cards drift on their own</p>
</section>