Copied SVG to clipboard
Something went wrong
Copied code to clipboard
Something went wrong
Saved to bookmarks!
Removed from bookmarks
Webflow Challenge: Win $5K

Default

User image

Default

Name

  • -€50
    Upgrade to Lifetime
The Vault/

3D Perspective Hover

3D Perspective Hover

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

Copy
<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

Copy
<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

Copy
.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

Copy
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

Copy
[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

GSAP
Hover
Follow
Movement
Interactive
Direction
Rotate
Transform

Original source

Ilja van Eck

Creator Credits

We always strive to credit creators as accurately as possible. While similar concepts might appear online, we aim to provide proper and respectful attribution.