3D Perspective Hover
A GSAP-powered 3D tilt effect that rotates any registered element toward the global pointer position. Inner layers with translateZ depth planes create a natural parallax stack as the parent tilts. Auto-disables on touch devices and respects prefers-reduced-motion.
Setup — External Scripts
<script src="https://cdn.jsdelivr.net/npm/gsap@3.13.0/dist/gsap.min.js"></script>Code
<div class="perspective__wrap">
<div data-3d-hover-target data-max-rotate="20" class="perspective__item">
<div class="perspective__item-bg">
<img src="https://cdn.prod.website-files.com/68ef4c46363f989d59b1b825/68ef55198c19c748f995050b_Modern%20Stylish%20Portrait.avif" class="perspective__img">
</div>
<div data-3d-hover-inner="layer-1" class="perspective__item-secondary">
<img src="https://cdn.prod.website-files.com/68ef4c46363f989d59b1b825/68ef5f6943c95c6a14127724_secondary-glasses.avif" class="perspective__img">
</div>
<div data-3d-hover-inner="layer-3" class="perspective__item-title">
<span class="pespective__item-span">Supernova</span>
<span class="pespective__item-span is--secondary">€129.00</span>
</div>
<div data-3d-hover-inner="layer-2" class="perspective__item-save">
<svg xmlns="http://www.w3.org/2000/svg" width="100%" viewBox="0 0 24 21" fill="none">
<path d="M11.9982 3.66647L10.2649 2.06648C9.10545 0.988716 7.58113 0.389648 5.99818 0.389648C4.41522 0.389648 2.8909 0.988716 1.73151 2.06648C0.653754 3.22586 0.0546875 4.75018 0.0546875 6.33313C0.0546875 7.91606 0.653754 9.44046 1.73151 10.5998L11.9982 20.9998L22.2649 10.5998C23.3426 9.44046 23.9417 7.91606 23.9417 6.33313C23.9417 4.75018 23.3426 3.22586 22.2649 2.06648C21.1054 0.988716 19.5811 0.389648 17.9982 0.389648C16.4153 0.389648 14.8909 0.988716 13.7315 2.06648L11.9982 3.66647Z" fill="currentColor"></path>
</svg>
</div>
<div data-3d-hover-inner="layer-4" class="perspective__item-colors">
<span>Available in:</span>
<div class="perspective__item-color"></div>
<div class="perspective__item-color is--black"></div>
<div class="perspective__item-color is--green"></div>
</div>
<div data-3d-hover-inner="layer-4" class="perspective__item-cart">
<svg xmlns="http://www.w3.org/2000/svg" width="100%" viewBox="0 0 24 24" fill="none">
<path d="M5 7H21L19 15.5H7L4 3.5H1" stroke="currentColor" stroke-miterlimit="10"></path>
<path d="M19 20C19 19.1716 18.3284 18.5 17.5 18.5C16.6716 18.5 16 19.1716 16 20C16 20.8284 16.6716 21.5 17.5 21.5C18.3284 21.5 19 20.8284 19 20Z" stroke="currentColor" stroke-miterlimit="10"></path>
<path d="M10 20C10 19.1716 9.32843 18.5 8.5 18.5C7.67157 18.5 7 19.1716 7 20C7 20.8284 7.67157 21.5 8.5 21.5C9.32843 21.5 10 20.8284 10 20Z" stroke="currentColor" stroke-miterlimit="10"></path>
</svg>
</div>
</div>
</div>.perspective__wrap {
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
display: flex;
position: relative;
}
.perspective__item {
aspect-ratio: 1 / 1.33;
justify-content: center;
align-items: center;
width: clamp(30em, 35vw, 45em);
display: flex;
position: relative;
}
.perspective__item-bg {
z-index: 0;
border: .3125em solid #fff;
border-radius: .5em;
width: 100%;
height: 100%;
position: relative;
}
.perspective__img {
object-fit: cover;
object-position: 50% 100%;
width: 100%;
height: 100%;
}
.perspective__item-secondary {
z-index: 1;
aspect-ratio: 1;
border: .3125em solid #fff;
border-radius: 100em;
width: 50%;
position: absolute;
bottom: -10%;
left: -25%;
overflow: hidden;
}
.perspective__item-title {
z-index: 1;
color: #39090d;
background-color: #fff;
border-radius: .5em;
flex-flow: column;
justify-content: flex-start;
align-items: flex-start;
padding: .875em 4em .875em 1.25em;
display: flex;
position: absolute;
top: 5%;
right: -20%;
}
.pespective__item-span {
font-variation-settings: "wght" 500;
font-size: 1.5em;
}
.pespective__item-span.is--secondary {
opacity: .5;
}
.perspective__item-save {
z-index: 2;
background-color: #c8313e;
border-radius: 100em;
justify-content: center;
align-items: center;
width: 4em;
height: 4em;
padding: 1.25em;
display: flex;
position: absolute;
bottom: -2em;
left: 20%;
}
.perspective__item-colors {
grid-column-gap: .25em;
grid-row-gap: .25em;
-webkit-backdrop-filter: blur(5px);
backdrop-filter: blur(5px);
background-color: #ffffff4d;
border-radius: 100em;
grid-template-rows: auto auto;
grid-template-columns: 1fr 1fr;
grid-auto-columns: 1fr;
justify-content: center;
align-items: center;
padding: .5em 1em;
display: flex;
position: absolute;
bottom: 15%;
right: -10%;
}
.perspective__item-color {
background-color: #fff;
border-radius: 100em;
width: 1em;
height: 1em;
}
.perspective__item-color.is--black {
background-color: #000;
}
.perspective__item-color.is--green {
background-color: #0ed100;
}
.perspective__item-cart {
-webkit-backdrop-filter: blur(5px);
backdrop-filter: blur(5px);
background-color: #ffffff4d;
border-radius: 100em;
width: 5em;
height: 5em;
padding: 1.5em;
position: absolute;
top: 15%;
left: -1em;
}
@media screen and (max-width: 767px) {
.perspective__item {
font-size: .75em;
}
}
@media screen and (max-width: 479px) {
.perspective__item {
font-size: .45em;
}
}
[data-3d-hover-target] {
transform: perspective(50vw);
transform-style: preserve-3d;
will-change: transform;
}
[data-3d-hover-inner="layer-1"] {
transform: translateZ(3vw);
}
[data-3d-hover-inner="layer-2"] {
transform: translateZ(5vw);
}
[data-3d-hover-inner="layer-3"] {
transform: translateZ(6vw);
}
[data-3d-hover-inner="layer-4"] {
transform: translateZ(8vw);
}function init3dPerspectiveHover() {
// Skip on touch / non-hover devices
const canHover = window.matchMedia?.('(hover: hover) and (pointer: fine)').matches;
if (!canHover) return () => {};
// Skip if there's no targets on page
const nodeList = document.querySelectorAll('[data-3d-hover-target]');
if (!nodeList.length) return () => {};
// Skip if user prefers reduced motion
if (window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) return () => {};
const DEFAULT_MAX_DEG = 20;
const EASE = 'power3.out';
const DURATION = 0.5;
const targets = Array.from(nodeList).map((el) => {
const maxAttr = parseFloat(el.getAttribute('data-max-rotate'));
const maxRotate = Number.isFinite(maxAttr) ? maxAttr : DEFAULT_MAX_DEG;
const setRotationX = gsap.quickSetter(el, 'rotationX', 'deg');
const setRotationY = gsap.quickSetter(el, 'rotationY', 'deg');
return {
el,
maxRotate,
rect: el.getBoundingClientRect(),
proxy: { rx: 0, ry: 0 },
setRotationX,
setRotationY,
};
});
let mouseX = window.innerWidth / 2;
let mouseY = window.innerHeight / 2;
let isFrameScheduled = false;
function measureAll() {
for (const target of targets) {
target.rect = target.el.getBoundingClientRect();
}
}
function onPointerMove(event) {
mouseX = event.clientX;
mouseY = event.clientY;
if (!isFrameScheduled) {
isFrameScheduled = true;
requestAnimationFrame(updateAll);
}
}
function updateAll() {
isFrameScheduled = false;
for (const target of targets) {
const { rect, maxRotate, proxy, setRotationX, setRotationY } = target;
const centerX = rect.left + rect.width / 2;
const centerY = rect.top + rect.height / 2;
const normX = Math.max(-1, Math.min(1, (mouseX - centerX) / ((rect.width / 2) || 1)));
const normY = Math.max(-1, Math.min(1, (mouseY - centerY) / ((rect.height / 2) || 1)));
const rotationY = normX * maxRotate;
const rotationX = -normY * maxRotate;
gsap.to(proxy, {
rx: rotationX,
ry: rotationY,
duration: DURATION,
ease: EASE,
overwrite: true,
onUpdate: () => {
setRotationX(proxy.rx);
setRotationY(proxy.ry);
}
});
}
}
// stable listener so we can remove them later
function onResize() { requestAnimationFrame(measureAll); }
function onScroll() { requestAnimationFrame(measureAll); }
// init
measureAll();
document.addEventListener('pointermove', onPointerMove, { passive: true });
window.addEventListener('resize', onResize, { passive: true });
window.addEventListener('scroll', onScroll, { passive: true });
// expose cleanup
function destroy() {
document.removeEventListener('pointermove', onPointerMove);
window.removeEventListener('resize', onResize);
window.removeEventListener('scroll', onScroll);
}
return destroy;
}
// Initialize 3D Perspective Hover
document.addEventListener('DOMContentLoaded', () => {
init3dPerspectiveHover();
});Attributes
| Name | Type | Default | Description |
|---|---|---|---|
| [data-3d-hover-target] | attribute | — | Registers the element for 3D tilt tracking. The element rotates toward the global pointer position. Multiple elements on the same page are all tracked independently. |
| [data-max-rotate] | number | 20 | Maximum rotation in degrees for this specific target. Fine-tune per element without affecting others. Defaults to 20 if the attribute is absent or not a finite number. |
| [data-3d-hover-inner="layer-x"] | "layer-1" | "layer-2" | "layer-3" | "layer-4" | — | Marks a child element as a depth layer inside a [data-3d-hover-target]. Each layer gets a translateZ() value in CSS so it sits at a different depth plane, creating a parallax effect as the parent tilts. |
Notes
- •Requires GSAP loaded via CDN before the script runs.
- •Uses gsap.quickSetter for rotationX/Y — this bypasses the normal GSAP tween overhead on every pointer event and is the recommended approach for high-frequency updates.
- •All bounding rect measurements are deferred to requestAnimationFrame on resize and scroll to avoid layout thrashing.
- •Multiple [data-3d-hover-target] elements on the same page share a single pointermove listener — there is no per-element listener overhead.
- •The function returns a destroy() cleanup function. For SPA / page transition setups, always call it when unmounting to prevent memory leaks.
Guide
Target
Use [data-3d-hover-target] to register any element for 3D tilt tracking based on the global pointer position.
Max Rotate
Use [data-max-rotate] to fine-tune the maximum rotation per element without affecting other [data-3d-hover-target] elements. If you don't add this attribute, it will default to a maximum rotation of 20 degrees.
Perspective
Use transform: perspective(...) on [data-3d-hover-target] to make the element tilt in true 3D space. The regular perspective property only affects children, not the element itself. Since this is defined in CSS, you can have different perspective values for different targets.
Inner Layers
Use [data-3d-hover-inner="layer-x"] inside a [data-3d-hover-target] to add separate depth planes that move naturally as the parent tilts. You can define as many layers as you like, each with its own translateZ() value to control how far it sits in 3D space. Larger translateZ values make the layer appear closer to the viewer, creating a stronger parallax effect.
Pointer Devices Only
The script auto-disables on non-hover devices using (hover: hover) and (pointer: fine) so [data-3d-hover-target] won't attach listeners on touch-only screens.
Reduced Motion
When the user prefers reduced motion, the script respects prefers-reduced-motion: reduce and does not run.
Basic Initialization
Attach the script globally; all [data-3d-hover-target] elements start responding automatically.
document.addEventListener('DOMContentLoaded', () => {
init3dPerspectiveHover();
});Initialization with cleanup
If you dynamically remove sections (for example, when using page transitions with a library like BarbaJS), store the returned destroy function so you can later remove all event listeners and free up memory. The basic init runs once and stays active for the entire session, while the cleanup version gives you full control to stop the behavior whenever your layout changes or the section is unmounted.
const destroy3dHover = init3dPerspectiveHover();
// Later, when tearing down the section:
destroy3dHover();