Handwriting SVG Animation for React
SVG path that draws itself on mount. Pass path for custom shapes or text to render handwriting.
Component
"use client";
import { motion } from "framer-motion";
import * as opentype from "opentype.js";
import { useEffect, useState } from "react";
import { cn } from "@/lib/utils";
const DEFAULT_FONT_URL =
"https://raw.githubusercontent.com/google/fonts/main/ofl/indieflower/IndieFlower-Regular.ttf";
interface HandwritingSvgProps {
path?: string;
text?: string;
fontUrl?: string;
className?: string;
strokeClassName?: string;
duration?: number;
delay?: number;
strokeWidth?: number;
width?: number;
height?: number;
fontSize?: number;
ease?: "linear" | "easeIn" | "easeOut" | "easeInOut";
}
export function HandwritingSvg({
path: pathProp,
text,
fontUrl = DEFAULT_FONT_URL,
className,
strokeClassName,
duration = 2,
delay = 0.5,
strokeWidth = 2,
width = 100,
height = 100,
fontSize = 48,
ease = "easeInOut",
}: HandwritingSvgProps) {
const [path, setPath] = useState<string | null>(
pathProp ?? null,
);
const [viewBox, setViewBox] = useState(
`${0} ${0} ${width} ${height}`,
);
const [loading, setLoading] = useState(
!!text && !pathProp,
);
useEffect(() => {
if (!text || pathProp) {
setPath(pathProp ?? null);
setViewBox(`0 0 ${width} ${height}`);
setLoading(false);
return;
}
let cancelled = false;
setLoading(true);
fetch(fontUrl)
.then((res) => res.arrayBuffer())
.then((buffer) => {
if (cancelled) {
return;
}
const font = opentype.parse(buffer);
const p = font.getPath(text, 0, fontSize, fontSize);
const bbox = p.getBoundingBox();
const pad = 5;
const vx = Math.floor(bbox.x1) - pad;
const vy = Math.floor(bbox.y1) - pad;
const vw = Math.ceil(bbox.x2 - bbox.x1) + pad * 2;
const vh = Math.ceil(bbox.y2 - bbox.y1) + pad * 2;
setViewBox(`${vx} ${vy} ${vw} ${vh}`);
setPath(p.toPathData(2));
})
.catch(() => {
if (!cancelled) {
setPath(null);
}
})
.finally(() => {
if (!cancelled) {
setLoading(false);
}
});
return () => {
cancelled = true;
};
}, [text, fontUrl, pathProp, fontSize, width, height]);
if (loading) {
return (
<svg
width={width}
height={height}
viewBox={`0 0 ${width} ${height}`}
className={cn("text-muted-foreground", className)}
aria-hidden={true}
>
<title>Handwriting SVG loading</title>
<text
x="50%"
y="50%"
textAnchor="middle"
dominantBaseline="middle"
fontSize={14}
>
Loading…
</text>
</svg>
);
}
const d = path ?? "";
if (!d) {
return (
<svg
width={width}
height={height}
viewBox={`0 0 ${width} ${height}`}
className={cn("text-muted-foreground", className)}
aria-hidden={true}
>
<title>Handwriting SVG</title>
<text
x="50%"
y="50%"
textAnchor="middle"
dominantBaseline="middle"
fontSize={12}
>
{text ? "Invalid font" : "Provide path or text"}
</text>
</svg>
);
}
const svgViewBox = pathProp
? `0 0 ${width} ${height}`
: viewBox;
return (
<svg
width={width}
height={height}
viewBox={svgViewBox}
className={cn("text-rose-500", className)}
aria-hidden={true}
>
<title>Handwriting SVG</title>
<motion.path
d={d}
fill="none"
stroke="currentColor"
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
className={strokeClassName}
initial={{ pathLength: 0 }}
animate={{ pathLength: 1 }}
transition={{ delay, duration, ease }}
/>
</svg>
);
}Installation
1. Install dependencies
pnpm add framer-motion opentype.js@1.3.42. Copy the component and types file
"use client";
import { motion } from "framer-motion";
import * as opentype from "opentype.js";
import { useEffect, useState } from "react";
import { cn } from "@/lib/utils";
const DEFAULT_FONT_URL =
"https://raw.githubusercontent.com/google/fonts/main/ofl/indieflower/IndieFlower-Regular.ttf";
interface HandwritingSvgProps {
path?: string;
text?: string;
fontUrl?: string;
className?: string;
strokeClassName?: string;
duration?: number;
delay?: number;
strokeWidth?: number;
width?: number;
height?: number;
fontSize?: number;
ease?: "linear" | "easeIn" | "easeOut" | "easeInOut";
}
export function HandwritingSvg({
path: pathProp,
text,
fontUrl = DEFAULT_FONT_URL,
className,
strokeClassName,
duration = 2,
delay = 0.5,
strokeWidth = 2,
width = 100,
height = 100,
fontSize = 48,
ease = "easeInOut",
}: HandwritingSvgProps) {
const [path, setPath] = useState<string | null>(
pathProp ?? null,
);
const [viewBox, setViewBox] = useState(
`${0} ${0} ${width} ${height}`,
);
const [loading, setLoading] = useState(
!!text && !pathProp,
);
useEffect(() => {
if (!text || pathProp) {
setPath(pathProp ?? null);
setViewBox(`0 0 ${width} ${height}`);
setLoading(false);
return;
}
let cancelled = false;
setLoading(true);
fetch(fontUrl)
.then((res) => res.arrayBuffer())
.then((buffer) => {
if (cancelled) {
return;
}
const font = opentype.parse(buffer);
const p = font.getPath(text, 0, fontSize, fontSize);
const bbox = p.getBoundingBox();
const pad = 5;
const vx = Math.floor(bbox.x1) - pad;
const vy = Math.floor(bbox.y1) - pad;
const vw = Math.ceil(bbox.x2 - bbox.x1) + pad * 2;
const vh = Math.ceil(bbox.y2 - bbox.y1) + pad * 2;
setViewBox(`${vx} ${vy} ${vw} ${vh}`);
setPath(p.toPathData(2));
})
.catch(() => {
if (!cancelled) {
setPath(null);
}
})
.finally(() => {
if (!cancelled) {
setLoading(false);
}
});
return () => {
cancelled = true;
};
}, [text, fontUrl, pathProp, fontSize, width, height]);
if (loading) {
return (
<svg
width={width}
height={height}
viewBox={`0 0 ${width} ${height}`}
className={cn("text-muted-foreground", className)}
aria-hidden={true}
>
<title>Handwriting SVG loading</title>
<text
x="50%"
y="50%"
textAnchor="middle"
dominantBaseline="middle"
fontSize={14}
>
Loading…
</text>
</svg>
);
}
const d = path ?? "";
if (!d) {
return (
<svg
width={width}
height={height}
viewBox={`0 0 ${width} ${height}`}
className={cn("text-muted-foreground", className)}
aria-hidden={true}
>
<title>Handwriting SVG</title>
<text
x="50%"
y="50%"
textAnchor="middle"
dominantBaseline="middle"
fontSize={12}
>
{text ? "Invalid font" : "Provide path or text"}
</text>
</svg>
);
}
const svgViewBox = pathProp
? `0 0 ${width} ${height}`
: viewBox;
return (
<svg
width={width}
height={height}
viewBox={svgViewBox}
className={cn("text-rose-500", className)}
aria-hidden={true}
>
<title>Handwriting SVG</title>
<motion.path
d={d}
fill="none"
stroke="currentColor"
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
className={strokeClassName}
initial={{ pathLength: 0 }}
animate={{ pathLength: 1 }}
transition={{ delay, duration, ease }}
/>
</svg>
);
}3. Import and use
import { HandwritingSvg } from "@/components/handwriting-svg";
<HandwritingSvg path="M50 10 L90 50 L50 90 L10 50 Z" />
<HandwritingSvg text="Hello" />Usage
Import
Add the HandwritingSvg import.
import { HandwritingSvg } from "@/components/handwriting-svg";Path
Use with path for custom SVG.
<HandwritingSvg path="M50 10 L90 50 L50 90 L10 50 Z" />;Text
Or use text for handwriting rendering.
<HandwritingSvg text="Hello" />;Guidelines
- Use path for custom SVG path data (d attribute).
- Use text to render text as handwriting; requires fontUrl (default: Indie Flower).
- Animates on mount. duration and delay control timing.
Props
All props are optional unless marked required. Use these to customize every aspect of the component.
| Prop | Type | Default | Description |
|---|---|---|---|
| className | string | - | Additional CSS classes for the SVG (e.g. text-rose-500 for stroke). |
| strokeClassName | string | - | Classes for the animated path stroke. |
| path | string | - | Custom SVG path (d attribute). Use when not using text. |
| text | string | - | Text to render as handwriting. Uses opentype.js with fontUrl. |
| fontUrl | string | Indie Flower TTF URL | Font URL for text rendering. TTF or OTF. |
| duration | number | 2 | Draw animation duration in seconds. |
| delay | number | 0.5 | Delay before animation starts (seconds). |
| ease | "linear" | "easeIn" | "easeOut" | "easeInOut" | "easeInOut" | Animation easing: linear, easeIn, easeOut, easeInOut. |
| width | number | 100 | SVG width in pixels. |
| height | number | 100 | SVG height in pixels. |
| fontSize | number | 48 | Font size for text rendering. |
| strokeWidth | number | 2 | Stroke width in pixels. |
Accessibility
- Purely decorative visual effect with no interactive controls, focusable elements, or keyboard handlers.
- The SVG is marked aria-hidden so assistive technology skips it; the inline title element is therefore not announced, so convey any meaning conveyed by the drawing through adjacent visible text.
- When rendering text input the glyphs are drawn as SVG strokes rather than real text, so screen readers receive no readable equivalent and a visible or visually-hidden text label should accompany it.
- The component does not check prefers-reduced-motion, so the draw-on-mount animation always plays; it should be guarded with a reduced-motion query that renders the final path immediately for users who prefer reduced motion.
Performance
- Animates only the SVG pathLength via framer-motion, which drives stroke-dasharray and stroke-dashoffset; this avoids layout and paint of geometry but stroke-dashoffset is not GPU-composited, so it repaints each frame.
- Animation runs once on mount only with no scroll or visibility gating, so offscreen instances still animate; an IntersectionObserver could defer work until visible.
- Text rendering performs a network font fetch through opentype.js in a useEffect, gated behind a loading state, and uses a cancelled flag in the cleanup to avoid setting state after unmount.
- No requestAnimationFrame, timers, event listeners, IntersectionObserver, or will-change hints are used, so there are no manual listeners or timers to leak beyond the single fetch guarded by the cleanup.
Examples
Path
Custom SVG path draws on mount.
import { HandwritingSvg } from "@/components/handwriting-svg";
<HandwritingSvg path="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" />;Text input
Text rendered as handwriting using a font.
import { HandwritingSvg } from "@/components/handwriting-svg";
<HandwritingSvg text="Hi" className="text-amber-500" />
<HandwritingSvg text="Hello" width={120} height={60} />Custom input
Type your name and click Draw to see it rendered as handwriting.
import { HandwritingSvg } from "@/components/handwriting-svg";
<HandwritingSvg text="Pulkit" />;Related reading
- Choreographing Multi-Step Motion — Animating SVG stroke-dashoffset over keyframes
- Easing Curves That Feel Natural — Choosing easing so the writing feels hand-drawn
Last updated on Jun 19