Handwriting SVG
SVG path that draws itself on mount. Pass path for custom shapes or text to render handwriting.
Last updated Mar 5, 2026
Component
"use client";
import { motion } from "framer-motion";
import 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);
opentype
.load(fontUrl)
.then((font) => {
if (cancelled) {
return;
}
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
pnpm dlx shadcn@latest add "https://pulkitxm.com/components/handwriting-svg.json"1. Install dependencies
pnpm add framer-motion opentype.js2. Copy the component and types file
"use client";
import { motion } from "framer-motion";
import 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);
opentype
.load(fontUrl)
.then((font) => {
if (cancelled) {
return;
}
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. |
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" />;