
Documentation
Webflow
Code
Setup: External Scripts
External Scripts in Webflow
Make sure to always put the External Scripts before the Javascript step of the resource.
In this video you learn where to put these in your Webflow project? Or how to include a paid GSAP Club plugin in your project?
HTML
<script src="https://cdn.jsdelivr.net/npm/gsap@3.13.0/dist/gsap.min.js"></script>
Step 1: Copy structure to Webflow
Copy structure to Webflow
In the video below we described how you can copy + paste the structure of this resource to your Webflow project.
Copy to Webflow
Webflow structure is not required for this resource.
Step 1: Add HTML
HTML
<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>
HTML structure is not required for this resource.
Step 2: Add CSS
CSS
.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);
}
Step 2: Add custom Javascript
Custom Javascript in Webflow
In this video, Ilja gives you some guidance about using JavaScript in Webflow:
Step 2: Add Javascript
Step 3: Add Javascript
Javascript
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;
}
document.addEventListener('DOMContentLoaded', () => {
init3dPerspectiveHover();
});
Step 3: Add custom CSS
Step 2: Add custom CSS
Custom CSS in Webflow
Curious about where to put custom CSS in Webflow? Ilja explains it in the below video:
CSS
[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);
}
Implementation
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 could 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.
[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); }
/* Etc... */
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), use the version that stores the returned destroy function so you can later remove all event listeners and free up memory.
document.addEventListener('DOMContentLoaded', () => {
const destroyTilt = init3dPerspectiveHover();
// later: destroyTilt();
});
The difference between both methods is that 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.
Resource Details
Last updated
October 15, 2025
Type
The Vault
Category
Hover Interactions
Need help?
Join Slack