Search code examples
javascriptanimationcss-animationsanimejs

Trying to animate Images around a Ring using CSS / JS or with help of a library like animeJS


Aside from some basic transitions, i'm pretty much a novice when it comes to animations in css/JS. For a Project that starts next week, i have commited to a specific animation that i find somewhat challenging. Here's the Animation explained:

  • There should be 10-30 Images that rotate/circulate around a Ring/circle.
    • The Images should also randomly have a small offset away from the Ring, as to give a "scattered" effect
  • The Images should be spread around the ring to "fill" it.
  • The Images should have a "bounce" effect or similar

Image of what i'd hope to achieve: Animation Image

What i've tried so far: I've been researching some JS Libraries for Animations. Those that stood out to me were animeJS (https://animejs.com/) and MoJS (https://mojs.github.io/). I've decided to test out AnimeJS.

Here is a CodePen: CodePen

const imgContainer = document.getElementById("imgContainer");
const svgContainer = document.getElementById("svgContainer");

const imgURL =
  "https://images.unsplash.com/photo-1678833823181-ec16d450d8c1?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=774&q=80";

function generateCircleWithRandomSizes(index) {
  const randomSize = generateRandomNumberBetween(450, 520);
  return `<div class="svgWrapper">
<svg width="${randomSize}" height="${randomSize}" fill="none" stroke="none" id="svgContainer">
            <path id="path${index + 1}" fill="none" d="
                m ${(800 - randomSize) / 2}, ${800 - randomSize}
                a 1,1 0 1,1 ${randomSize},0
                a 1,1 0 1,1 -${randomSize},0
                " />
        </svg>
</div>`;
}

function generateRandomNumberBetween(min, max) {
  return Math.floor(Math.random() * (max - min + 1) + min);
}

for (let i = 0; i < 30; i++) {
  imgContainer.innerHTML += `<img class="image" id="img${i + 1}" src="${imgURL}" />`;

  svgContainer.innerHTML += generateCircleWithRandomSizes(i);

  let path = anime.path(`#path${i + 1}`);

  anime({
    targets: `#img${i + 1}`,
    translateX: path("x"),
    translateY: path("y"),
    easing: "linear",
    duration: 10000,
    loop: true
  });
}

Here's the approach i'm currently trying, but as the CodePen shows, i'm running into multiple issues. I'm generating as many Circles as i have images and want target 1 Image per Path. This approach would give me the scattered effect i'm trying to get. However, as you can see, it seems that animeJS only animates one of the Images, and that it seems to follow the path, but offset to the top left. I'm assuming this has something to do with my CSS and how i "center" SVG/Path, which ultimately shows another issue i have. I'm not quite sure how to dynamically generate theses rings and always center them.

I'm having a bit of a hard time to put my finger on how to best solve this. Am i using the best library for this use case? Do i even need a library? Should i got at it from a completely different angle?

I'd really love to get some help on this.


Solution

  • You current anime.js code only animates the last element as you're breaking the previous element bindings by using innerHTML().

    As an alternative you could use insertAdjacentHTML() as explained here "Is it possible to append to innerHTML without destroying descendants' event listeners?"

      imgContainer.insertAdjacentHTML('beforeend', `<img class="image" id="img${
        i + 1}" src="${imgURL}" />`) ;
    
      svgContainer.insertAdjacentHTML('beforeend', generateCircleWithRandomSizes(i)); 
    

    Example: anime.js

    const ns = "http://www.w3.org/2000/svg";
    const svgContainer = document.getElementById("svgContainer");
    const svgEl = document.getElementById("svg");
    const defs = document.getElementById("defs");
    const imgContainer = document.getElementById("imgContainer");
    
    const imgURL =
      "https://images.unsplash.com/photo-1678833823181-ec16d450d8c1?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=774&q=80";
    
    function generateCircleWithRandomSizes(index) {
      let randomSize = generateRandomNumberBetween(400, 500);
      let newMpath = document.createElementNS(ns, "path");
      let d = `M ${(800 - randomSize) / 2}, ${800 - randomSize}
                    a 1,1 0 1,1 ${randomSize},0
                    a 1,1 0 1,1 -${randomSize},0z`;
      newMpath.setAttribute("d", d);
      newMpath.id = `path${index}`;
      defs.appendChild(newMpath);
    }
    
    function generateRandomNumberBetween(min, max) {
      return Math.floor(Math.random() * (max - min + 1) + min);
    }
    
    for (let i = 0; i < 10; i++) {
      generateCircleWithRandomSizes(i);
    
      let newImg = document.createElement("img");
      newImg.id = "img" + i;
      newImg.src = imgURL;
      newImg.classList.add("image");
      imgContainer.appendChild(newImg);
    
      let path = anime.path(`#path${i}`);
      let target = document.querySelector(`#img${i}`);
    
    
      anime({
        targets: target,
        translateX: path("x"),
        translateY: path("y"),
        easing: "linear",
        duration: 10000,
        loop: true
      });
    }
    body {
      margin: 0;
    }
    
    .container {
      display: flex;
      justify-content: center;
      align-items: center;
      height: 100vh;
      width: 100vw;
      background-color: lightblue;
    }
    
    .inner {
      height: 1000px;
      width: 1000px;
      display: flex;
      justify-content: center;
      align-items: center;
      background: lightpink;
    }
    
    .imgContainer img {
      width: 10%;
      height: 15%;
      position: absolute;
      top: -100px;
      left: -50px;
    }
    
    #svgContainer {
      position: relative;
      height: 800px;
      width: 800px;
    }
    
    .svgWrapper {
      position: absolute;
      top: 0;
      left: 0;
    }
    
    path {
      stroke: black;
      stroke-width: 1px;
      fill: none;
    }
    
    @keyframes bouncing {
      0% {
        bottom: 0;
        box-shadow: 0 0 5px rgba(250, 250, 0, 0.3);
      }
      100% {
        bottom: 50px;
        box-shadow: 0 50px 50px rgba(250, 0, 0, 0.2);
      }
    }
    <script src="https://cdnjs.cloudflare.com/ajax/libs/animejs/3.2.1/anime.min.js"></script>
    
    <body>
      <div class="container">
        <div class="inner">
          <div class="imgContainer" id="imgContainer">
    
          </div>
    
          <div id="svgContainer">
            <svg viewBox="0 0 800 800" fill="none" stroke="#000" id="svg">
              <g id="defs"></g>
          </div>
        </div>
      </div>
    </body>

    Alternative: SVG SMIL <AnimateMotion>

    const ns = "http://www.w3.org/2000/svg";
    const svgContainer = document.getElementById("svgContainer");
    const svgEl = document.getElementById("svg");
    const defs = document.getElementById("defs");
    const duration = 6;
    
    // image array
    let images = [
      { src: "https://placehold.co/100x100/green/FFF", width: 100, height: 100 },
      { src: "https://placehold.co/100x100/orange/FFF", width: 100, height: 100 },
      { src: "https://placehold.co/100x100/red/FFF", width: 100, height: 100 }
    ];
    
    for (let i = 0; i < images.length; i++) {
      
      // generate random motion paths
      generateCircleWithRandomSizes(i);
      
      // create svg <image> elements
      let img = images[i];
      let newImage = document.createElementNS(ns, "image");
      newImage.setAttribute("x", -img.width / 2);
      newImage.setAttribute("y", -img.height / 2);
      newImage.setAttribute("width", img.width);
      newImage.setAttribute("height", img.height);
      newImage.setAttribute("href", img.src);
      newImage.id = `img${i}`;
    
      // define animation
      let animateMotion = document.createElementNS(ns, "animateMotion");
      animateMotion.setAttribute("begin", `-${(duration / images.length) * i} `);
      animateMotion.setAttribute("dur", `${duration}`);
      animateMotion.setAttribute("repeatCount", "indefinite");
      let mpath = document.createElementNS(ns, "mpath");
      mpath.setAttribute("href", `#path${i}`);
    
      // append elements
      animateMotion.appendChild(mpath);
      newImage.appendChild(animateMotion);
      svg.appendChild(newImage);
    
    }
    
    function generateCircleWithRandomSizes(index) {
      let randomSize = generateRandomNumberBetween(400, 500);
      let newMpath = document.createElementNS(ns, "path");
      let d = `M ${(800 - randomSize) / 2}, ${800 - randomSize}
                    a 1,1 0 1,1 ${randomSize},0
                    a 1,1 0 1,1 -${randomSize},0z`;
      newMpath.setAttribute("d", d);
      newMpath.id = `path${index}`;
      defs.appendChild(newMpath);
    }
    
    function generateRandomNumberBetween(min, max) {
      return Math.floor(Math.random() * (max - min + 1) + min);
    }
    svg{
      width:50%;
      border:1px solid #ccc
    }
    
    image{
       animation: 0.5s bouncing forwards infinite;
    }
    
    
    @keyframes bouncing {
      0% {
        transform: scale(1)
      }
      50% {
        transform: translate(10px, 10px) scale(1)
      }
      100% {
        transform: scale(1)
      }
    }
        <div id="svgContainer">
          <svg viewBox="0 0 800 800" fill="none" stroke="#000" id="svg">
            <g id="defs"></g>
          </svg>
        </div>

    In this example you could append your images as <image> elements to your <svg> element.

    <!-- define motion path -->
    <defs>
      <path d="M 175, 350a 1,1 0 1,1 450,0a 1,1 0 1,1 -450,0z" id="path0"/>
    </defs>
    <image x="-50" y="-50" width="100" height="100" href="https://placehold.co/100x100/green/FFF" id="img0">
        <animateMotion begin="-10 " dur="6" repeatCount="indefinite">
            <!-- reference motion path -->
            <mpath href="#path0"></mpath>
        </animateMotion>
    </image>
    

    The path offset can be achieved by a negative begin value.
    As explained here "Offseting animation start point for SVG AnimateMotion".

    Alternative 2: offset-path

    Disclaimer: Currently not fully implemented by a lot of browsers (especially webkit /safari).

    const duration = 6;
    
    // image array
    let images = [
      { src: "https://placehold.co/100x100/green/FFF", width: 100, height: 100 },
      { src: "https://placehold.co/100x100/orange/FFF", width: 100, height: 100 },
      { src: "https://placehold.co/100x100/red/FFF", width: 100, height: 100 }
    ];
    
    for (let i = 0; i < images.length; i++) {
      //generate random motion paths
      let d = generateCircleWithRandomSizes(i, 400, 520);
    
      // create svg <image> elements
      let img = images[i];
      let newImage = document.createElement("img");
      newImage.classList.add('image');
      newImage.setAttribute("width", img.width);
      newImage.setAttribute("height", img.height);
      newImage.setAttribute("src", img.src);
      newImage.id = `img${i}`;
    
    
      // append elements
      imgContainer.appendChild(newImage);
      
      // define offset path
      newImage.style["offset-path"] = `path('${d}')`;  
      newImage.style["offset-rotate"] = `0deg`;
      let delay = (100 / images.length) * i;
    
      newImage.animate(
        [
          { offsetDistance: `${0 + delay}%` },
          { offsetDistance: `${100 + delay}%` }
        ],
        {
          duration: duration*1000,
          iterations: Infinity
        }
      );
    }
    
    function generateCircleWithRandomSizes(index, r1=400, r2=500) {
      let randomSize = generateRandomNumberBetween(r1, r2);
      let d = `M ${(800 - randomSize) / 2}, ${
        800 - randomSize
      }a 1,1 0 1,1 ${randomSize},0a 1,1 0 1,1 -${randomSize},0z`;
      return d;
    }
    
    function generateRandomNumberBetween(min, max) {
      return Math.floor(Math.random() * (max - min + 1) + min);
    }
    svg{
      width:50%;
      border:1px solid #ccc
    }
    
    .image{
      position:absolute;
      animation: 0.5s bouncing forwards infinite;
    }
    
    
    @keyframes bouncing {
      0% {
        transform: scale(1)
      }
      50% {
        transform: translate(10px, 10px) scale(1)
      }
      100% {
        transform: scale(1)
      }
    }
    <div class="imgContainer" id="imgContainer">
    </div>

    Hopefully, we see better support in the near future. The main benefits:

    • specify a motion path in css - no need of a svg element.
    • Starting offsets can easily controlled in css via offset-distance property.