skip to Main Content

I have an horizontally responsive SVG path that stretch without keeping its ratio, and it’s working when using preserveAspectRatio="none" on the tag and vector-effect="non-scaling-stroke" on the path to keep it intact. But I also need to animate an element along that same path without losing its aspect ratio.

Here is an example and as you can see it’s working if you resize horizontally the wrapper but not for the star that loses its aspect-ratio caused by the global preserveAspectRatio="none" attribute. I can’t find a workaround for that and have both the path horizontally responsive and keep the star intact and following the path…

.wrapper {
    width: 100%;
    min-width: 200px;
    max-width: 100%;
    height: 200px;
    resize: horizontal;
    overflow: hidden;
    border: 1px solid blue;
}
svg {
    width: 100%;
    height: 100%;
}
.path {
    fill: none;
    stroke: black;
    stroke-width: 2px;
    stroke-dasharray: 6 6;
    stroke-linecap:round;
}
.star {
    fill: red;
}
<div class="wrapper">
        <svg viewBox="0 0 400 200" preserveAspectRatio="none">
            <defs>
                <path id="star" d="M0,24.675L2.679,32.921L11.349,32.921L4.335,38.017L7.014,46.262L0,41.166L-7.014,46.262L-4.335,38.017L-11.349,32.921L-2.679,32.921L0,24.675Z"/>
                <path id="path" d="M0,36.608C0,36.608 64.096,15.519 92.956,48.531C121.816,81.542 101.293,129.283 74.824,115.941C48.354,102.599 68.017,24.7 188.557,73.454C309.097,122.207 261.935,170.513 400,175.664" vector-effect="non-scaling-stroke"/>
            </defs>
            <use href="#path" class="path"/>
            <use href="#star" class="star" x="0" y="-36">
                <animateMotion dur="10s" repeatCount="indefinite" rotate="auto">
                    <mpath href="#path"/>
                </animateMotion>
            </use>
        </svg>
    </div>

2

Answers


  1. I though I had a great idea by replacing the original svg star with a star-anchor element. So that I could have a second SVG, which contains the actual star that keeps its aspect ratio, follow the star-anchor.
    Feel free to have a look. Perhaps its giving you any ideas.
    But I don’t think the edges on this approach can be smoothed out in a satisfying manner.

    const star = document.querySelector(".star-svg");
    
    function moveStarToAnchor() { 
      const anchorRect = document.querySelector(".star_anchor").getBoundingClientRect();
      star.style.translate = anchorRect.x+ "px " + anchorRect.y + "px";
    }
    
    setInterval(moveStarToAnchor, 100);
    .wrapper {
      width: 100vw;
      height: 100vh;
      overflow: hidden;
      border: 1px solid blue;
      display: grid;
      position: relative;
    }
    .path-svg {
      width: 100%;
      height: 100%;
      grid-column: 1/2;
      grid-row: 1/2;
      position: absolute;
    }
    
    .star-svg {
      fill: red;
      grid-column: 1/2;
      grid-row: 1/2;
      position: absolute;
      top: 0;
      left: 0;
      width: 50px;
      height: 50px;
      transition: translate 100ms;
    }
    
    .path {
      fill: none;
      stroke: black;
      stroke-width: 2px;
      stroke-dasharray: 6 6;
      stroke-linecap: round;
    }
    
    body {
      margin: 0;
    }
    
    * {
      box-sizing: border-box;
    }
    <div class="wrapper">
      <svg class="path-svg" viewBox="0 0 400 200" preserveAspectRatio="none">
        <defs>
          <path id="star_anchor" d="M0,24.675L2.679" />
          <path id="path" d="M0,36.608C0,36.608 64.096,15.519 92.956,48.531C121.816,81.542 101.293,129.283 74.824,115.941C48.354,102.599 68.017,24.7 188.557,73.454C309.097,122.207 261.935,170.513 400,175.664" vector-effect="non-scaling-stroke" />
        </defs>
        <use href="#path" class="path" />
        <use href="#star_anchor" class="star_anchor" x="0" y="-36">
          <animateMotion dur="10s" repeatCount="indefinite" rotate="auto">
            <mpath href="#path" />
          </animateMotion>
        </use>
      </svg>
      <svg class="star-svg"  viewBox="0 0 10 10" width="10" height="10">
          <circle r="5" cx="5" cy="5" fill="red" />
      </svg>
    </div>

    Here is the code example for how to calculate the distorted path in JS.
    Your path is using cubic-bezier coordinates. I do not yet fully understand how they work. Therefore my example uses line coordinates. I think that makes it easier to understand anyways.

    const originalPath = "M0 50 L50 0 L100 50 L50 100 Z";
    const containerLength = 200; // this represents a users viewport size
    const svgViewPortLength = 100; // this is taken from the SVG viewport
    const scallingCoefficient = containerLength / svgViewPortLength;
    const originalCoords = originalPath.match(/d+/g);
    const newCoords = originalCoords.map((c) => Number(c) * scallingCoefficient);
    const scaledPath = createLinePath(newCoords);
    setTimeout(() => applyNewPath(scaledPath), 500);
    
    function applyNewPath(path) {
      const pathElement = document.querySelector("path");
      pathElement.setAttribute("d", path);
    }
    
    function createLinePath(coords) {
      return (
        newCoords?.slice(1).reduce((prev, curr, index) => {
          prev += " ";
          if (index % 2 === 1) {
            prev += "L";
          }
          prev += curr;
          return prev;
        }, "M" + coords[0]) + " Z"
      );
    }
    svg {
      width: 200px;
      height: 200px;
      background: skyblue;
    }
    <div class="wrapper">
      <svg viewPort="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
        <path d="M0 50 L50 0 L100 50 L50 100 Z" style="fill:none;stroke:green;stroke-width:3" />
      </svg>
    </div>
    Login or Signup to reply.
  2. Unfortunately we can’t change the track of a motion path via transform or prevent unproportional scaling for specific elements.

    A workaround could be to update the motion path on resize:

    • we need to parse the motion path data
    • add a ResizeObserver
    • scale the motion path geometry via a helper function (scalePathData())

    We can’t specify a fixed viewBox – otherwise the animated element would be scaled/"squeezed" as well.

    Basically we’re updating scaleX and scaleY values on resize based on a comparison between the original motion path bounding box and the resized SVG aspect ratio.

    let svg = document.querySelector('svg');
    let motionPath = svg.querySelector('#path')
    
    // parse path for transformations
    let d = motionPath.getAttribute('d')
    let pathData = motionPath.getPathData({
      normalize: true
    });
    
    // original bounding box of motion path
    let bbO = motionPath.getBBox();
    
    
    function scaleMotionPath(svg, selector) {
      let bb = svg.getBoundingClientRect();
      let scaleX = bb.width / (bbO.width + bbO.x * 2);
      let scaleY = bb.height / (bbO.height + bbO.y * 2);
    
      // clone path data to prevent overwriting
      let pathDataNew = JSON.parse(JSON.stringify(pathData));
      let pathDataScaled = scalePathData(pathDataNew, scaleX, scaleY);
    
      // update path data
      path.setPathData(pathDataScaled)
    
    }
    
    let resizeObserver = new ResizeObserver(entries => {
      let svg = entries[0].target;
      let aspect = scaleMotionPath(svg)
    });
    
    resizeObserver.observe(svg);
    
    
    
    /**
     * scale pathData
     */
    function scalePathData(pathData, scaleX, scaleY) {
      pathData.forEach((com, i) => {
        let {
          type,
          values
        } = com;
        let typeRel = type.toLowerCase();
    
        switch (typeRel) {
          case "a":
            com.values = [
              values[0] * scaleX,
              values[1] * scaleY,
              values[2],
              values[3],
              values[4],
              values[5] * scaleX,
              values[6] * scaleY
            ];
            break;
    
          case "h":
            com.values = [values[0] * scaleX];
            break;
    
          case "v":
            com.values = [values[0] * scaleY];
            break;
    
          default:
            if (values.length) {
              for (let i = 0; i < values.length; i += 2) {
                com.values[i] *= scaleX;
                com.values[i + 1] *= scaleY;
              }
            }
        }
    
      });
      return pathData;
    }
    .wrapper {
      width: 100%;
      max-width: 100%;
      resize: both;
      overflow: auto;
      border: 1px solid blue;
    }
    
    svg {
      width: 100%;
      height: 100%;
      max-width: 100%;
      max-height: 100%;
      outline: 1px solid red;
      overflow: visible;
    }
    
    .path {
      fill: none;
      stroke: black;
      stroke-width: 2px;
      stroke-dasharray: 6 6;
      stroke-linecap: round;
    }
    
    .star {
      fill: red;
    }
    <div class="wrapper">
      <svg>
        <defs>
          <path id="star" d="M 0 -12 l 2.7 8.2 l 8.6 0 l -7 5.1 l 2.7 8.3 l -7 -5.1 l -7 5.1 l 2.7 -8.3 l -7 -5.1 l 8.6 0 l 2.7 -8.2 z" />
          <path id="path"  d="M0,36.608C0,36.608 64.096,15.519 92.956,48.531C121.816,81.542 101.293,129.283 74.824,115.941C48.354,102.599 68.017,24.7 188.557,73.454C309.097,122.207 261.935,170.513 400,175.664" vector-effect="non-scaling-stroke" />
        </defs>
        <use id="usePath" href="#path" class="path" />
        <use href="#star" class="star" >
          <animateMotion dur="10s" repeatCount="indefinite" rotate="auto">
            <mpath href="#path" />
          </animateMotion>
        </use>
      </svg>
    </div>
    
    
    <script src="https://cdn.jsdelivr.net/npm/[email protected]/path-data-polyfill.min.js"></script>

    The above example is based on the SVGPathData interface draft structure parsed via Jarek Foksa’s polyfill.

    See also "css-tricks.com:Create a Responsive CSS Motion Path? Sure We Can!"

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search