Outline Orbit

When I was writing the article about will-change in CSS I came up with this fun visual effect, where the button has multiple outlines that orbit around it. I was pretty happy with the result so I figured I'd share it.

Structure

The structure of the component is very simple. We start by creating the button. We already know that we want the button outline to rotate as well, so we make the background of the button its own div.

<button
  className="button-container"
  onMouseEnter={() => setIsHovered(true)}
  onMouseLeave={() => setIsHovered(false)}
>
  <span className="button-text">Button</span>
  <div className="button-bg" />
</button>
SVG Setup

There are probably other ways this effect could be achieved, but the one that made sense to me was using SVGs. The basic idea is to recreate the button shape using the rect shape and then animate the outline using stroke.


We start by defining the rectangles as constants with width, height and the colors.

const rectangles = [
  {
    width: 200,
    height: 56,
    normalColor: "stroke-sky-500",
    hoverColor: "stroke-sky-400",
  },
  {
    width: 256,
    height: 112,
    normalColor: "stroke-sky-500/25",
    hoverColor: "stroke-sky-400/35",
  },
  {
    width: 312,
    height: 168,
    normalColor: "stroke-sky-500/10",
    hoverColor: "stroke-sky-400/20",
  },
];

We also define the stroke width as a constant as it's going to get used in multiple places down the line.

const strokeWidth = 2;

The SVG is positioned absolutely and the width and height are passed down from the rectangles that we defined earlier. The stroke-width value is also added to both the size as well as the viewbox.


Without adding the extra pixels for the stroke-width the stroke would get cropped off by the viewbox.

<svg
  key={index}
  className="pointer-events-none absolute left-1/2 top-1/2 shrink-0 -translate-x-1/2 -translate-y-1/2 will-change-transform"
  width={rect.width + strokeWidth}
  height={rect.height + strokeWidth}
  viewBox={`0 0 ${rect.width + strokeWidth} ${rect.height + strokeWidth}`}
>
  {/*Rectangle*/}
</svg>

It's pretty simple to recreate the button shape using the rect SVG shape. By using rx={rect.height / 2} we can create a perfect pill shape, same as the button.

Fun Fact

If you only specify rx, the SVG automatically sets ry to the same value. So you can just define rx="rect.height / 2" instead of defining both.

The rect also gets turned into a motion component as this is where the animation is going to occur.

<motion.rect
  x={strokeWidth / 2}
  y={strokeWidth / 2}
  width={rect.width}
  height={rect.height}
  rx={rect.height / 2}
  fill="none"
  strokeWidth={strokeWidth}
  strokeDasharray="4 4"
  className={clsx(
    "transition-colors duration-500 ease-out",
    isHovered ? rect.hoverColor : rect.normalColor
  )}
/>

Finally, we just map over the SVGs.

{
  rectangles.map((rect, index) => (
    <svg
      key={index}
      className="pointer-events-none absolute left-1/2 top-1/2 shrink-0 -translate-x-1/2 -translate-y-1/2 will-change-transform"
      width={rect.width + strokeWidth}
      height={rect.height + strokeWidth}
      viewBox={`0 0 ${rect.width + strokeWidth} ${rect.height + strokeWidth}`}
    >
      <motion.rect
        x={strokeWidth / 2}
        y={strokeWidth / 2}
        width={rect.width}
        height={rect.height}
        rx={rect.height / 2}
        fill="none"
        strokeWidth={strokeWidth}
        strokeDasharray="4 4"
        className={clsx(
          "transition-colors duration-500 ease-out",
          isHovered ? rect.hoverColor : rect.normalColor
        )}
      />
    </svg>
  ));
}
Animating the Stroke

First, we use the useMotionValue hook from Motion. The value starts at 0 and this is the value that is going to control all of the animations.

const progress = useMotionValue(0);

Then we assign useTransform to each of the rectangles. It converts the progress value from 0 to 1, which then changes the strokeDashoffset between the defined values.


Negative values mean that the stroke will rotate clockwise, while positive values rotate clockwise, although this depends on the element's path direction.

const innerBorderOffset = useTransform(progress, [0, 1], [0, -64]);
const middleBorderOffset = useTransform(progress, [0, 1], [0, 64]);
const outerBorderOffset = useTransform(progress, [0, 1], [0, -64]);

The animation controls are set to loop using the animate() function to create a "master" animation that animates all strokes simultaneously. They are wrapped in a useEffect to make sure it only runs when the component mounts and not on every render.


If we used the animate prop on the motion.rect instead, each stroke would get animated separately.

useEffect(() => {
  const controls = animate(progress, 1, {
    duration: 3,
    ease: "linear",
    repeat: Infinity,
    repeatType: "loop",
  });
 
  return () => controls.stop();
}, [progress]);
Dash Offset

This specific example could be simplified, as all of the strokeDashoffset values are the same and you could control the duration only in the animation controls. However, this way, we can customize the speed and direction of each stroke.

const BorderOffset = useTransform(progress, [0, 1], [0, -1]);

Higher strokeDashoffset means the stroke will animate faster, while lower means it will animate slower. So while each cycle has the same duration, individual strokes can animate slower or faster depending on the strokeDashoffset value.


The visual of the animation can also be tweaked by changing the strokeDasharray values. The first value defines the length of the dash while the second defines the gap between the dashes.

strokeDasharray = "4 4";
Final Animation

After setting everything up, the only remaining thing is to add strokeDashoffset to the motion.rect and the animation is complete.

<motion.rect
  x={strokeWidth / 2}
  y={strokeWidth / 2}
  width={rect.width}
  height={rect.height}
  rx={rect.height / 2}
  fill="none"
  strokeWidth={strokeWidth}
  strokeDasharray="4 4"
  className={clsx(
    "transition-colors duration-500 ease-out",
    isHovered ? rect.hoverColor : rect.normalColor
  )}
  strokeDashoffset={strokeDashoffsets[index]}
/>

We can now further play around with the rectangle sizes, animation control values or strokeDashoffset values to get the desired effect.

More

In case you have any questions reach me at jakub@kbo.sk or see more of my work on Twitter.