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.
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>
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>
));
}
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]);
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";
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.
In case you have any questions reach me at jakub@kbo.sk or see more of my work on Twitter.