Animated dashed borders
I recently had to create a dropzone component with an inset dashed border, which I decided I also wanted to animate.
The design required precise control over the dash pattern, but the CSS border
property offers limited flexibility. You can make it dashed and adjust its width, but you can't define the dash array.
I tried a linear-gradient
trick (like this one), but it doesn’t play nicely with border-radius
.
In the end, I used an SVG, which gives full control over both the dash pattern and corner radius. This generator came in handy when testing.
The SVG is absolutely positioned as an overlay to fill the parent component. The animation runs only on hover, applied programmatically since pointer-events: none
keeps the SVG from blocking interaction with underlying elements.
Here is the final result:
The code
The SVG <rect>
is inset by half the stroke width so the stroke stays fully visible inside the viewBox. To keep the SVG from intercepting mouse events, pointer-events: none
is applied. In Tailwind, this means you can’t rely on :hover
styles directly, so the animation has to be triggered programmatically.
SVG needs concrete pixel dimensions for consistent dash spacing. Given that this needed to be used within a responsive layout, I used useMeasure
from @uidotdev/usehooks
. This approach doesn’t work with SSR, since measuring happens on the client, but given that this was for an SPA it was an acceptable trade-off.
export function AnimatedDashedBorder(props: AnimatedDashedBorderProps) {
const {
borderRadius = 8,
animationDuration = 1,
dashArray = "4 4",
strokeWidth = 1,
width = 0,
height = 0,
active = false,
children,
className,
...rest
} = props;
const rectWidth = width - strokeWidth;
const rectHeight = height - strokeWidth;
return (
<svg
width={width}
height={height}
viewBox={`0 0 ${width} ${height}`}
className={cn("pointer-events-none absolute inset-0", className)}
{...rest}
>
<rect
x={strokeWidth / 2}
y={strokeWidth / 2}
width={rectWidth}
height={rectHeight}
rx={borderRadius}
ry={borderRadius}
fill="none"
stroke="currentColor"
strokeWidth={strokeWidth}
strokeDasharray={dashArray}
style={{
transition: "stroke 0.2s",
animation: active
? `dashoffset-move ${animationDuration}s linear infinite`
: undefined,
}}
/>
<style>
{`@keyframes dashoffset-move {
to {
stroke-dashoffset: -8;
}
}`}
</style>
</svg>
);
}
It can then be used like this:
export function Dropzone() {
const [isHovering, setIsHovering] = useToggle(false);
const [ref, { width, height }] = useMeasure();
return (
<Box
className="bg-background-surface-interactive text-border-strong w-full rounded-xl p-1 hover:text-purple-500"
onMouseEnter={() => setIsHovering(true)}
onMouseLeave={() => setIsHovering(false)}
>
<Box
ref={ref}
className="relative flex cursor-pointer items-center justify-center gap-x-3 p-4"
>
<Box className="text-text-muted flex items-center gap-x-2 transition-all group-hover:text-purple-400">
<Upload className="size-4" />
<Box className="text-sm">Drop your files here</Box>
</Box>
<AnimatedDashedBorder
width={width}
height={height}
dashArray="4 4"
strokeWidth={1}
borderRadius={8}
animationDuration={1}
active={isHovering}
/>
</Box>
</Box>
);
}