Search code examples
javascriptaddeventlistenermousemovemouse-cursor

Multiple image trails following cursor with Javascript


I'm trying to create multiple image trails for a grid. Each trail follow the cursor, I found an example on Codepen to illustrate the effect I'm looking for. The example is made with GSAP, but for this project I would prefer not using any libraries.

I've success to make the block of images following the cursor but I don't find the way to reproduce the effect in Javascript.

const posts = document.querySelectorAll('.js-post');

let activePost = null;
let activeCursor = null;
let currentX = 0, 
    currentY = 0;
let aimX = 0, 
    aimY = 0;
const speed = 0.2;

const animate = () => {
  if (activeCursor) {
    currentX += (aimX - currentX) * speed;
    currentY += (aimY - currentY) * speed;
    activeCursor.style.left = currentX + 'px';
    activeCursor.style.top = currentY + 'px';
  }
  requestAnimationFrame(animate);
};

animate();

posts.forEach(post => {
  
  

post.addEventListener('mouseenter', (e) => {
    // Hide the previous grid element's cursor immediately, if any.
    if (activePost && activePost !== post && activeCursor) {
      activeCursor.classList.remove('is-visible');
      // Reset the previous cursor to 0,0 relative to its container.
      activeCursor.style.left = '0px';
      activeCursor.style.top = '0px';
    }
    activePost = post;
    activeCursor = post.querySelector('.js-cursor');

    // Get grid item's bounding rectangle for local coordinate conversion.
    const rect = post.getBoundingClientRect();
    currentX = e.clientX - rect.left;
    currentY = e.clientY - rect.top;
    aimX = currentX;
    aimY = currentY;
    
    // Position the cursor immediately at the mouse's location.
    activeCursor.style.left = currentX + 'px';
    activeCursor.style.top = currentY + 'px';
    activeCursor.classList.add('is-visible');
  });

  post.addEventListener('mousemove', (e) => {
    if (activePost === post && activeCursor) {
      const rect = post.getBoundingClientRect();
      aimX = e.clientX - rect.left;
      aimY = e.clientY - rect.top;
    }
  });

  post.addEventListener('mouseleave', () => {
    if (activePost === post && activeCursor) {
      activeCursor.classList.remove('is-visible');
      // Reset the coordinates to the top-left (0,0) of the grid element.
      activeCursor.style.left = '0px';
      activeCursor.style.top = '0px';
      // Also reset the internal coordinates so the next activation starts from 0,0.
      currentX = 0;
      currentY = 0;
      aimX = 0;
      aimY = 0;
      activePost = null;
      activeCursor = null;
    }
  });
});
body{
  font-family: 'helvetica', arial, sans-serif;
}

.grid{
  display: grid;
  width: 100%;
  grid-template-columns: repeat(2, 1fr);
  grid-column-gap: 1rem;
  grid-row-gap: 1rem;
}

.grid__item{
  display: flex;
  justify-content: center;
  align-content: center;
  position: relative;
  padding: 25%;
  overflow: hidden;
  background-color: #333;
}

.grid__item-number{
   color: #888;
   font-size: 5rem;
}

.grid__item-cursor{
  position: absolute;
  width: 150px;
  height: 200px;
  transform: translate(-50%, -50%);
  pointer-events: none;
  z-index: -1;
  opacity: 0;
  
  transition: opacity .3s ease .1s;
}

.grid__item-cursor.is-visible{
  z-index: 1;
  opacity: 1;
}

.grid__item-image{
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
}
<div class="grid">
  
  <div class="grid__item js-post">
    <div class="grid__item-number">1</div>
    <div class="grid__item-cursor js-cursor">
       <img class="grid__item-image js-image" src="https://cdn.pixabay.com/photo/2022/11/13/18/09/canyon-7589820_1280.jpg">
      <img class="grid__item-image js-image" src="https://cdn.pixabay.com/photo/2022/11/02/22/33/autumn-7566201_1280.jpg">
      <img class="grid__item-image js-image" src="https://cdn.pixabay.com/photo/2023/04/05/09/44/landscape-7901065_1280.jpg">
      <img class="grid__item-image js-image" src="https://cdn.pixabay.com/photo/2020/09/04/16/18/mountains-5544365_1280.jpg">
    </div>
  </div>
  
  <div class="grid__item js-post">
    <div class="grid__item-number">2</div>
    <div class="grid__item-cursor js-cursor">
      <img class="grid__item-image js-image" src="https://cdn.pixabay.com/photo/2022/11/13/18/09/canyon-7589820_1280.jpg">
      <img class="grid__item-image js-image" src="https://cdn.pixabay.com/photo/2022/11/02/22/33/autumn-7566201_1280.jpg">
      <img class="grid__item-image js-image" src="https://cdn.pixabay.com/photo/2023/04/05/09/44/landscape-7901065_1280.jpg">
      <img class="grid__item-image js-image" src="https://cdn.pixabay.com/photo/2020/09/04/16/18/mountains-5544365_1280.jpg">
    </div>
  </div>
  
  <div class="grid__item js-post">
    <div class="grid__item-number">3</div>
    <div class="grid__item-cursor js-cursor">
      <img class="grid__item-image js-image" src="https://cdn.pixabay.com/photo/2022/11/13/18/09/canyon-7589820_1280.jpg">
      <img class="grid__item-image js-image" src="https://cdn.pixabay.com/photo/2022/11/02/22/33/autumn-7566201_1280.jpg">
      <img class="grid__item-image js-image" src="https://cdn.pixabay.com/photo/2023/04/05/09/44/landscape-7901065_1280.jpg">
      <img class="grid__item-image js-image" src="https://cdn.pixabay.com/photo/2020/09/04/16/18/mountains-5544365_1280.jpg">
    </div>
  </div>
  
  <div class="grid__item js-post">
    <div class="grid__item-number">4</div>
    <div class="grid__item-cursor js-cursor">
      <img class="grid__item-image js-image" src="https://cdn.pixabay.com/photo/2022/11/13/18/09/canyon-7589820_1280.jpg">
      <img class="grid__item-image js-image" src="https://cdn.pixabay.com/photo/2022/11/02/22/33/autumn-7566201_1280.jpg">
      <img class="grid__item-image js-image" src="https://cdn.pixabay.com/photo/2023/04/05/09/44/landscape-7901065_1280.jpg">
      <img class="grid__item-image js-image" src="https://cdn.pixabay.com/photo/2020/09/04/16/18/mountains-5544365_1280.jpg">
    </div>
  </div>
  
</div>


Solution

  • To create a staggered effect of images following the pointer, each image should interpolate its current position towards the position of the next image. Obviously, since the first image does not have any following one, that element position interpolates towards the pointer's XY:

    newX = lerp(currentX, targetX, factor);
    

    (where targetX is either the pointer's X or the following Element's X).

    To retrieve the current position and assign the new x, y I've used the CSS Properties (CSS Vars) with .getPropertyValue() and .setProperty() respectively.

    const lerp = (curr, target, factor) => curr + (target - curr) * factor;
    const factor = 0.15; // Higher value = faster
    const pointer = {
      x: 0,
      y: 0,
      update({pageX, pageY, currentTarget}) {
        this.x = pageX - currentTarget.offsetLeft;
        this.y = pageY - currentTarget.offsetTop;
      }
    };
    
    let raf;
    let elsImages;
    
    const animate = (isLerp) => {
      let {x, y} = pointer; // Init with the Pointer's XY coordinates
      elsImages.forEach((el) => {
        const xNew = lerp(+(el.style.getPropertyValue("--x") ?? 0), x, isLerp ? factor : 1);
        const yNew = lerp(+(el.style.getPropertyValue("--y") ?? 0), y, isLerp ? factor : 1);
        el.style.setProperty("--x", xNew);
        el.style.setProperty("--y", yNew);
        x = xNew; // then, store image's coordinates
        y = yNew; 
      });
      raf = requestAnimationFrame(animate);
    };
    
    const start = (evt) => {
      pointer.update(evt);
      elsImages = evt.currentTarget.querySelectorAll(".js-image");
      animate(false);
      evt.currentTarget.classList.add("is-visible");
    };
    
    const move = (evt) => {
      pointer.update(evt);
    };
    
    const stop = (evt) => {
      elsImages = undefined;
      cancelAnimationFrame(raf);
      evt.currentTarget.classList.remove("is-visible");
    };
    
    document.querySelectorAll(".js-post").forEach((elBox) => {
      elBox.addEventListener("pointerenter", start);
      elBox.addEventListener("pointermove", move);
      elBox.addEventListener("pointerleave", stop);
    });
    body{
      font-family: "helvetica", arial, sans-serif;
    }
    
    .grid{
      display: grid;
      width: 100%;
      grid-template-columns: repeat(2, 1fr);
      touch-action: none;
      gap: 1rem;
    }
    
    .grid__item{
      position: relative;
      display: flex;
      justify-content: center;
      align-content: center;
      overflow: hidden;
      background-color: #333;
      aspect-ratio: 1;
      
      &::before {
        content: attr(data-number);
        color: #888;
        font-size: 5rem;
        margin: auto;
      }
    }
    
    .grid__item-image{
      position: absolute;
      pointer-events: none;
      top: 0;
      left: 0;
      width: 50%;
      height: 50%;
      object-fit: cover;
      opacity: 0;
      transform: translate(-50%, -50%);
      translate: calc(var(--x) * 1px) calc(var(--y) * 1px);
      transition: opacity .3s;
    
      .is-visible & {
        opacity: 1;
      }
    }
    <div class="grid">
      <div class="grid__item js-post" data-number="1">
        <img class="grid__item-image js-image" src="https://cdn.pixabay.com/photo/2022/11/13/18/09/canyon-7589820_1280.jpg">
        <img class="grid__item-image js-image" src="https://cdn.pixabay.com/photo/2022/11/02/22/33/autumn-7566201_1280.jpg">
        <img class="grid__item-image js-image" src="https://cdn.pixabay.com/photo/2023/04/05/09/44/landscape-7901065_1280.jpg">
        <img class="grid__item-image js-image" src="https://cdn.pixabay.com/photo/2020/09/04/16/18/mountains-5544365_1280.jpg">
      </div>
    
      <div class="grid__item js-post" data-number="2">
        <img class="grid__item-image js-image" src="https://cdn.pixabay.com/photo/2022/11/13/18/09/canyon-7589820_1280.jpg">
        <img class="grid__item-image js-image" src="https://cdn.pixabay.com/photo/2022/11/02/22/33/autumn-7566201_1280.jpg">
        <img class="grid__item-image js-image" src="https://cdn.pixabay.com/photo/2023/04/05/09/44/landscape-7901065_1280.jpg">
        <img class="grid__item-image js-image" src="https://cdn.pixabay.com/photo/2020/09/04/16/18/mountains-5544365_1280.jpg">
      </div>
    
      <div class="grid__item js-post" data-number="3">
        <img class="grid__item-image js-image" src="https://cdn.pixabay.com/photo/2022/11/13/18/09/canyon-7589820_1280.jpg">
        <img class="grid__item-image js-image" src="https://cdn.pixabay.com/photo/2022/11/02/22/33/autumn-7566201_1280.jpg">
        <img class="grid__item-image js-image" src="https://cdn.pixabay.com/photo/2023/04/05/09/44/landscape-7901065_1280.jpg">
        <img class="grid__item-image js-image" src="https://cdn.pixabay.com/photo/2020/09/04/16/18/mountains-5544365_1280.jpg">
      </div>
    
      <div class="grid__item js-post" data-number="4">
        <img class="grid__item-image js-image" src="https://cdn.pixabay.com/photo/2022/11/13/18/09/canyon-7589820_1280.jpg">
        <img class="grid__item-image js-image" src="https://cdn.pixabay.com/photo/2022/11/02/22/33/autumn-7566201_1280.jpg">
        <img class="grid__item-image js-image" src="https://cdn.pixabay.com/photo/2023/04/05/09/44/landscape-7901065_1280.jpg">
        <img class="grid__item-image js-image" src="https://cdn.pixabay.com/photo/2020/09/04/16/18/mountains-5544365_1280.jpg">
      </div>
    </div>

    PS, I've modified your HTML (removed unnecessary elements) and simplified as well the CSS. And it also works on touch devices.