Wave Box: An SVG Sine Wave Animation Built With React

I simply enjoy animations. I also really like the power of SVGs for creating images. In this demo we’ll use a bit of trigonometry math to create a slick SVG wave animation. See the sine wave in action in this YouTube video.

Sometimes we build cool stuff that has no practical purpose. This wave box may fall into that category, but if you get creative I’m sure you could think of uses: overlay it as a border, use it to draw viewers’ attention to an action modal, and so on.

View Code Sandbox Here.

I will step you through how I created this SVG sine wave animation in the code below. Keep in mind, we’ll start with the smallest, simplest pieces and add complexity as we go. At a high level, these are the steps:

  • Create the SVG box
  • Set some constants
  • Create a function for each of the sides
  • Tie it together

Ready?!

Step One: Create the SVG and path elements.

For now we’ll give the svg a defined width and height. In a future step we’ll make it dynamic.

return (
  <>
    <div style={{ maxWidth: "100%", paddingBottom: 12 }}>
      SVG sine wave animation. <br />
    </div>
    <svg
      width={250}
      height={250}
    >
      <path
        id="wave"
        stroke="teal"
        opacity="0.5"
        strokeWidth="2"
        strokeLinecap="round"
        fill="none"
      />
    </svg>
  </>
)

The primary item to notice here is we set the strokeWidth to 2. This results in a pretty thin line, but we still need to account for it when setting our constants later.

Step Two: Set our constants

We need eight constants…if we want to have really clean code. The first four constants exist simply to make sure the code is accessible to a non-math whiz. I’ll only bullet-point-explain the last four of them, take a look below to see why.

svg sine wave animation
One complete wave: Math.sin(2*Math.PI)
  • totalPoints – the total amount of points that will be used to calculate the four sides. This is really just composed of four constants which are included to make the code readable. Composing this constant also makes it easier to tweak just one constant. Here’s an explanation of each: First, one complete wave of the sine function (see image above) is equal to 2*PI. Second, frequency is one complete wave. A value of 3 gives us three total waves per side. Third, amplitude is the height/depth of a wave. Fourth, naturally we have four side to our box.
  • paddingOffset – we need this value to account for 1) the amplitude of the waves (10px) and 2) half of the <path> strokeWidth (1px). The paddingOffset will be used to give the waves enough space inside the <svg> container.
  • svgPathD – the array that contains all the points that will be injected into the <path> element.
  • pathFunction – our actual function for creating the wave. Notice that we use the amplitude value again. However, we have a different multiplier for frequency here. The frequencyPerSide constant only applies to the total points function.
const oneWave = 2 * Math.PI;
const frequencyPerSide = 3;
const amplitude = 10;
const numberOfSides = 4;

const totalPoints = Math.floor(
    oneWave * frequencyPerSide * amplitude * numberOfSides
);
const paddingOffset = 11;
const svgPathD = [];

const pathFunction = useCallback(point => {
  return Math.sin(point / 10) * amplitude;
  // Math.sin(x/frequency)*amplitude;
}, [amplitude]);

The more well-named constants you have, the less comments needed in your code.

Step Three: Create a function for each side

Each side requires a different function in order to properly calculate it’s wave. I’ll put this code into a gist since it’s a bit longer, or you can take a look below. These functions for calculating sides are called in a for loop that increments

Remember the padding offset? It comes into play here when calculating each side.

The top is the simplest to calculate. The x value is linear. The y value is the pathFunction (the wave), offset by, well, the paddingOffset. Keep in mind that higher y values push the line lower on the screen. calculateTop is only called when point is less than one quarter of the total points (point < 0.25 * totalPoints).

The right side requires that we start the x value at the farthest right that our box requires, then let it “wave” with our sine calculation. The y value is linear on this side, and simply needs to be the offset + an incrementing value. We get that incrementation by only calling calculateRight when point is one quarter of the way through the totalPoints (totalPoints * 0.25 <= point && point < totalPoints * 0.5). This means that if total points is 800, we’ll get values like 201 – 200, 202 – 200, 203 – 200, etc. These are added to the paddingOffset, then assigned to y. Thus we create an incrementing y value.

calculateBottom is called between one half and three quarters of the way through totalPoints. In calculateBottom, the x value needs to increment down, and the y value needs to oscillate.

calculateLeft is called in the final quarter of totalPoints. We are back to the x value oscillating, while the y value needs to increment down.

const calculateTop = useCallback(
    point => {
      const xyPoint = {
        x: paddingOffset + point,
        y: paddingOffset - pathFunction(point)
      };
      return ["L", xyPoint.x, xyPoint.y].join(" ");
    },
    [paddingOffset, pathFunction]
  );

  const calculateRight = useCallback(
    point => {
      const xyPoint = {
        x: paddingOffset + totalPoints * 0.25 + pathFunction(point),
        y: paddingOffset + (point - totalPoints * 0.25)
      };
      return ["L", xyPoint.x, xyPoint.y].join(" ");
    },
    [paddingOffset, pathFunction, totalPoints]
  );

  const calculateBottom = useCallback(
    point => {
      const xyPoint = {
        x: paddingOffset + totalPoints * 0.25 - (point - totalPoints / 2),
        y: paddingOffset + totalPoints * 0.25 + pathFunction(point)
      };
      return ["L", xyPoint.x, xyPoint.y].join(" ");
    },
    [paddingOffset, pathFunction, totalPoints]
  );

  const calculateLeft = useCallback(
    point => {
      const xyPoint = {
        x: -pathFunction(point) + paddingOffset,
        y: totalPoints * 0.25 - (point - totalPoints * 0.75) + paddingOffset
      };
      return ["L", xyPoint.x, xyPoint.y].join(" ");
    },
    [paddingOffset, pathFunction, totalPoints]
  );

Step Four: Tie it together

If it didn’t make sense exactly when the functions get called, take a look at this code. Notice the for loop and how we build one side at a time.

const createWave = useCallback(() => {
    svgPathD.push(["M", paddingOffset, paddingOffset].join(" ")); //path starting point
    
    for (let point = 1; point < totalPoints; point++) {
      if (point < totalPoints * 0.25) {
        svgPathD.push(calculateTop(point));
      } else if (totalPoints * 0.25 <= point && point < totalPoints * 0.5) {
        svgPathD.push(calculateRight(point));
      } else if (totalPoints * 0.5 <= point && point < totalPoints * 0.75) {
        svgPathD.push(calculateBottom(point));
      } else {
        svgPathD.push(calculateLeft(point));
      }
      point += 1;
    }

    svgPathD.push(["L", paddingOffset, paddingOffset].join(" ")); // connect back to path starting point
  }, [
    svgPathD,
    paddingOffset,
    totalPoints,
    calculateTop,
    calculateRight,
    calculateBottom,
    calculateLeft
  ]); // this is a dependency array.  It's a feature of react for observing objects.

Now, instead of the <path> element initially used in the first code snippet, we’ll replace that with a memoized constant.

const path = useMemo(() => {
    createWave();
    return (
      <path
        id="wave"
        stroke="teal"
        opacity="0.5"
        strokeWidth="2"
        strokeLinecap="round"
        fill="none" //fill the inside of the shape
        d={svgPathD.join(" ")}
      />
    );
  }, [svgPathD, createWave]);

Let’s go back and make the <svg> width and height dynamic. Each side of the box needs a length equal to one quarter of totalPoints + 2 * paddingOffset to ensure there is enough room for the waves.

<svg
   width={Math.ceil(totalPoints / 4 + paddingOffset * 2)} 
   height={Math.ceil(totalPoints / 4 + paddingOffset * 2)}
>

Then add in an animation in a styles file because we all love them. The stroke-dasharray turns the path from a solid line into a dashed line. However, we’re going to make it such big dashes that there’s really only one dash. Then, we’ll use stroke-dashoffset, animating backwards to 0, to reveal the dash. stroke-dashoffset: -1200px is actually moving our dash precisely 1200 px out of view, then the dashoffset is being removed, moving the dash into view.

#wave {
  animation: dash 3s linear forwards;
  stroke-dasharray: 1200px;
  stroke-dashoffset: -1200px;
}

@keyframes dash {
  to {
    stroke-dashoffset: 0;
  }
}

With all of this completed, you now have a slick SVG wave animation.

If you need an animation library with far more features out of the box, check out my comparison of Framer Motion and React-Spring animation libraries. They both have strengths and might meet the needs of your next project.

If you simply want to improve your JavaScript skills, take a look at these 50 challenging JavaScript questions.

Share this post:

Leave a Comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.