Line Draw SVG Animation for React
SVG path that draws itself on scroll, hover, or mount. Presets: diamond, circle, star, heart, arrow.
Component
Scroll to animate (diamond)
"use client";
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import {
useEffect,
useLayoutEffect,
useRef,
useState,
} from "react";
import { cn } from "@/lib/utils";
const PRESET_PATHS: Record<string, string> = {
arrow:
"M50 10 L90 50 L70 50 L70 90 L30 90 L30 50 L10 50 Z",
circle: "M50 5 A45 45 0 1 1 49.99 5",
diamond: "M50 10 L90 50 L50 90 L10 50 Z",
heart:
"M50 85 C20 60 5 35 25 15 C40 0 50 15 50 15 C50 15 60 0 75 15 C95 35 80 60 50 85 Z",
star: "M50 5 L61 40 L98 40 L68 60 L79 95 L50 75 L21 95 L32 60 L2 40 L39 40 Z",
};
type TriggerMode = "scroll" | "hover" | "mount";
type GsapEase =
| "power1.inOut"
| "power2.inOut"
| "power3.inOut"
| "power1.in"
| "power2.in"
| "elastic.out"
| "back.out";
interface LineDrawSvgProps {
path?: string;
preset?: keyof typeof PRESET_PATHS;
trigger?: TriggerMode;
className?: string;
strokeClassName?: string;
duration?: number;
ease?: GsapEase | string;
scrub?: boolean | number;
start?: string;
end?: string;
width?: number;
height?: number;
strokeWidth?: number;
}
export function LineDrawSvg({
path: pathProp,
preset = "diamond",
trigger = "scroll",
className,
strokeClassName,
duration = 1,
ease = "power2.inOut",
scrub = 1,
start = "top 80%",
end = "top 20%",
width = 120,
height = 120,
strokeWidth = 2,
}: LineDrawSvgProps) {
const svgRef = useRef<SVGSVGElement>(null);
const pathRef = useRef<SVGPathElement>(null);
const [hovered, setHovered] = useState(false);
const path =
pathProp ??
PRESET_PATHS[preset] ??
PRESET_PATHS.diamond;
useLayoutEffect(() => {
const pathEl = pathRef.current;
if (!pathEl) {
return;
}
const length = pathEl.getTotalLength();
pathEl.style.strokeDasharray = `${length}`;
pathEl.style.strokeDashoffset = `${length}`;
if (trigger === "mount") {
gsap.to(pathEl, {
duration,
ease,
strokeDashoffset: 0,
});
return;
}
if (trigger === "hover") {
return;
}
gsap.registerPlugin(ScrollTrigger);
const ctx = gsap.context(() => {
gsap.to(pathEl, {
duration,
ease,
scrollTrigger: {
end,
start,
trigger: svgRef.current,
},
scrub,
strokeDashoffset: 0,
});
});
return () => ctx.revert();
}, [trigger, duration, ease, scrub, start, end]);
useEffect(() => {
if (trigger !== "hover") {
return;
}
const pathEl = pathRef.current;
if (!pathEl) {
return;
}
const pathLength = pathEl.getTotalLength();
if (hovered) {
pathEl.style.strokeDasharray = `${pathLength}`;
gsap.to(pathEl, {
duration,
ease,
strokeDashoffset: 0,
});
} else {
gsap.to(pathEl, {
duration: duration * 0.5,
ease: "power2.in",
strokeDashoffset: pathLength,
});
}
}, [trigger, hovered, duration, ease]);
return (
<svg
ref={svgRef}
width={width}
height={height}
viewBox="0 0 100 100"
className={cn("overflow-visible", className)}
onMouseEnter={() =>
trigger === "hover" && setHovered(true)
}
onMouseLeave={() =>
trigger === "hover" && setHovered(false)
}
aria-hidden={true}
>
<title>Line draw SVG</title>
<path
ref={pathRef}
d={path}
fill="none"
stroke="currentColor"
strokeWidth={strokeWidth}
className={cn("text-violet-500", strokeClassName)}
/>
</svg>
);
}Installation
1. Install dependencies
pnpm add gsap2. Copy the component file
"use client";
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import {
useEffect,
useLayoutEffect,
useRef,
useState,
} from "react";
import { cn } from "@/lib/utils";
const PRESET_PATHS: Record<string, string> = {
arrow:
"M50 10 L90 50 L70 50 L70 90 L30 90 L30 50 L10 50 Z",
circle: "M50 5 A45 45 0 1 1 49.99 5",
diamond: "M50 10 L90 50 L50 90 L10 50 Z",
heart:
"M50 85 C20 60 5 35 25 15 C40 0 50 15 50 15 C50 15 60 0 75 15 C95 35 80 60 50 85 Z",
star: "M50 5 L61 40 L98 40 L68 60 L79 95 L50 75 L21 95 L32 60 L2 40 L39 40 Z",
};
type TriggerMode = "scroll" | "hover" | "mount";
type GsapEase =
| "power1.inOut"
| "power2.inOut"
| "power3.inOut"
| "power1.in"
| "power2.in"
| "elastic.out"
| "back.out";
interface LineDrawSvgProps {
path?: string;
preset?: keyof typeof PRESET_PATHS;
trigger?: TriggerMode;
className?: string;
strokeClassName?: string;
duration?: number;
ease?: GsapEase | string;
scrub?: boolean | number;
start?: string;
end?: string;
width?: number;
height?: number;
strokeWidth?: number;
}
export function LineDrawSvg({
path: pathProp,
preset = "diamond",
trigger = "scroll",
className,
strokeClassName,
duration = 1,
ease = "power2.inOut",
scrub = 1,
start = "top 80%",
end = "top 20%",
width = 120,
height = 120,
strokeWidth = 2,
}: LineDrawSvgProps) {
const svgRef = useRef<SVGSVGElement>(null);
const pathRef = useRef<SVGPathElement>(null);
const [hovered, setHovered] = useState(false);
const path =
pathProp ??
PRESET_PATHS[preset] ??
PRESET_PATHS.diamond;
useLayoutEffect(() => {
const pathEl = pathRef.current;
if (!pathEl) {
return;
}
const length = pathEl.getTotalLength();
pathEl.style.strokeDasharray = `${length}`;
pathEl.style.strokeDashoffset = `${length}`;
if (trigger === "mount") {
gsap.to(pathEl, {
duration,
ease,
strokeDashoffset: 0,
});
return;
}
if (trigger === "hover") {
return;
}
gsap.registerPlugin(ScrollTrigger);
const ctx = gsap.context(() => {
gsap.to(pathEl, {
duration,
ease,
scrollTrigger: {
end,
start,
trigger: svgRef.current,
},
scrub,
strokeDashoffset: 0,
});
});
return () => ctx.revert();
}, [trigger, duration, ease, scrub, start, end]);
useEffect(() => {
if (trigger !== "hover") {
return;
}
const pathEl = pathRef.current;
if (!pathEl) {
return;
}
const pathLength = pathEl.getTotalLength();
if (hovered) {
pathEl.style.strokeDasharray = `${pathLength}`;
gsap.to(pathEl, {
duration,
ease,
strokeDashoffset: 0,
});
} else {
gsap.to(pathEl, {
duration: duration * 0.5,
ease: "power2.in",
strokeDashoffset: pathLength,
});
}
}, [trigger, hovered, duration, ease]);
return (
<svg
ref={svgRef}
width={width}
height={height}
viewBox="0 0 100 100"
className={cn("overflow-visible", className)}
onMouseEnter={() =>
trigger === "hover" && setHovered(true)
}
onMouseLeave={() =>
trigger === "hover" && setHovered(false)
}
aria-hidden={true}
>
<title>Line draw SVG</title>
<path
ref={pathRef}
d={path}
fill="none"
stroke="currentColor"
strokeWidth={strokeWidth}
className={cn("text-violet-500", strokeClassName)}
/>
</svg>
);
}3. Import and use
import { LineDrawSvg } from "@/components/line-draw-svg";
<LineDrawSvg preset="diamond" trigger="scroll" />;Usage
Import
Add the LineDrawSvg import.
import { LineDrawSvg } from "@/components/line-draw-svg";Use
Use with preset and trigger.
<LineDrawSvg preset="diamond" trigger="scroll" />;Guidelines
- Use trigger='scroll' for scroll-triggered reveals (e.g. in long pages).
- Use trigger='hover' for interactive icons.
- Use trigger='mount' for immediate draw on page load.
- Presets: diamond, circle, star, heart, arrow.
- ease: power1.inOut, power2.inOut, elastic.out, back.out, etc.
Props
All props are optional unless marked required. Use these to customize every aspect of the component.
| Prop | Type | Default | Description |
|---|---|---|---|
| path | string | - | Custom SVG path (d attribute). Overrides preset. |
| preset | string | "diamond" | Preset shape: "diamond", "circle", "star", "heart", "arrow". |
| trigger | "scroll" | "hover" | "mount" | "scroll" | When to draw: "scroll", "hover", or "mount". |
| duration | number | 1 | Animation duration in seconds. |
| ease | string | "power2.inOut" | GSAP ease: power1.inOut, power2.inOut, elastic.out, back.out, etc. |
| scrub | boolean | number | 1 | ScrollTrigger scrub (true = smooth, number = seconds). |
| start | string | "top 80%" | ScrollTrigger start. |
| end | string | "top 20%" | ScrollTrigger end. |
| width | number | 120 | SVG width in pixels. |
| height | number | 120 | SVG height in pixels. |
| strokeWidth | number | 2 | Stroke width in pixels. |
| strokeClassName | string | - | Classes for the path stroke (e.g. text-amber-500). |
Accessibility
- Decorative visual effect: the svg is marked aria-hidden true so screen readers skip it, and there are no focusable or operable controls.
- The hover trigger uses onMouseEnter and onMouseLeave only, so it is pointer-only and cannot be activated or reversed via the keyboard.
- No element receives focus, so there is no focus ring or focus management to consider for this component.
- It does not check prefers-reduced-motion, so the draw animation runs unconditionally; it should be guarded with a matchMedia reduced-motion query to instantly show or hide the path for users who prefer reduced motion.
Performance
- Animation is driven entirely by the SVG strokeDashoffset property, which is paint-efficient for a thin single-path outline but is not a GPU-compositor-only transform like transform or opacity.
- GSAP and ScrollTrigger schedule updates on a shared requestAnimationFrame loop rather than per-tween timers, keeping the scroll-scrubbed draw smooth.
- Scroll mode wraps its tween in gsap.context and returns ctx.revert on cleanup, which kills the tween and removes the ScrollTrigger to prevent leaks on unmount.
- Path length is measured once via getTotalLength in useLayoutEffect to avoid layout thrash; the hover effect re-reads it per toggle, which is cheap for these small fixed presets.
- There is no IntersectionObserver, will-change hint, or visibility gating, and mount and hover tweens have no explicit kill on unmount, so rapid mounting of many instances relies on GSAP and element teardown for cleanup.
Examples
Scroll (diamond)
Diamond draws on scroll into view.
Scroll to animate (diamond)
import { LineDrawSvg } from "@/components/line-draw-svg";
<LineDrawSvg preset="diamond" trigger="scroll" />;Hover (star)
Star draws when user hovers.
Hover to animate (star)
import { LineDrawSvg } from "@/components/line-draw-svg";
<LineDrawSvg preset="star" trigger="hover" />;Mount (circle)
Circle draws immediately on mount.
Animates on mount (circle)
import { LineDrawSvg } from "@/components/line-draw-svg";
<LineDrawSvg preset="circle" trigger="mount" />;Size & easing
Larger size, thicker stroke, elastic easing.
Scroll to animate (diamond)
import { LineDrawSvg } from "@/components/line-draw-svg";
<LineDrawSvg
preset="heart"
duration={1.5}
ease="elastic.out"
width={160}
height={160}
strokeWidth={3}
/>;Custom path
Custom SVG path and stroke color.
Scroll to animate (diamond)
import { LineDrawSvg } from "@/components/line-draw-svg";
<LineDrawSvg
path="M10 50 L90 50"
trigger="mount"
strokeClassName="text-amber-500"
/>;Related reading
- Choreographing Multi-Step Motion — Drawing paths with animated stroke keyframes
- Invisible Scissors — Another reveal technique: clipping instead of stroking
Last updated on Jun 19