Animated SVG Signature Generator
Type your name and watch it draw itself on, letter by letter, like a real pen. A free animated SVG signature generator built as a copy-paste Vue 3 component — pick any Google Font, tune thickness, speed and stagger with live sliders, then download the finished SVG. Uses opentype.js to turn text into real SVG paths and animates stroke-dashoffset with a per-letter cascade. Works in Nuxt 3, Tailwind-ready, MIT licensed.
Vue
animated signature generatorsvg signature makerhandwritten text animationdraw svg animationsignature animation cssstroke dashoffsetsvg pen drawopentypevuenuxttailwindinteractive
Preview
Live demo of the component.
Loading font…
Usage
How to drop this into your project.
011. Install opentype.js
Parses the font at runtime and extracts one SVG path per letter.
example.vue
pnpm add opentype.js022. Drop a font file into /public/fonts/
Any TTF/OTF works. Pick a font with simple, uniform-weight glyphs — thin monoline sans (Quicksand Light, Poppins Thin, Jost Thin) or a playful display font (Puppies Play, Comfortaa) give the cleanest draw-on. High-contrast serifs (Playfair, Bodoni) also look great at a 1.2px stroke.
example.vue
# Grab a Google Font TTF and save it to /public/fonts/# Example: Puppies Play (default)curl -o public/fonts/PuppiesPlay.ttf \ "https://fonts.gstatic.com/s/puppiesplay/v11/wlp2gwHZEV99rG6M3NR9uB9vaA.ttf"033. Use with defaults
example.vue
<template> <AnimatedSignature initial="Your Name" /></template>044. Swap the font via the fontUrl prop
Point it at any TTF/OTF you dropped in /public/fonts/ (or a remote URL — the component fetches it). You'll usually want to tune strokeWidth alongside: thin geometric fonts look best at 1–1.6, chunky display fonts at 4–6.
example.vue
<template> <AnimatedSignature initial="Your Name" font-url="/fonts/QuicksandLight.ttf" :stroke-width="1.4" :duration="0.6" :stagger="0.22" /></template>055. Finding the Google Font URL
Google Fonts serves TTFs from fonts.gstatic.com. Hit the CSS2 API with a User-Agent, grep the .ttf URL out of the response, then download it.
example.vue
# Replace "Playwrite+IE" with any familycurl -sL -H "User-Agent: Mozilla/5.0" \ "https://fonts.googleapis.com/css2?family=Playwrite+IE" \ | grep -o "https://[^)]*\.ttf"Props
Everything you can pass in.
NameTypeDefaultDescription
fontUrlstring'/fonts/PuppiesPlay.ttf'Path (or URL) to a TTF/OTF font. Fetched at mount time and parsed with opentype.js. Swap it to use any font you like.initialstring'hello'Initial name rendered on mount. Also the seed for the text input.durationnumber0.6Seconds each letter takes to draw. Exposed as a live slider in the demo.staggernumber0.22Delay between the start of consecutive letters. Keep it below duration for a continuous cascade. Exposed as a live slider.delaynumber0.15Delay before the first letter starts.strokeWidthnumber1.2Stroke width of the outlined glyphs. Exposed as a live slider in the demo.Full source
Copy-paste the whole component.
Animated SVG Signature Generator.vue
<script setup lang="ts">import { ref, shallowRef, computed, watch, onMounted, onUnmounted } from 'vue'import opentype from 'opentype.js'
const props = withDefaults(defineProps<{ fontUrl?: string initial?: string duration?: number stagger?: number delay?: number strokeWidth?: number}>(), { fontUrl: '/fonts/PuppiesPlay.ttf', initial: 'hello', duration: 0.6, stagger: 0.22, delay: 0.15, strokeWidth: 1.2,})
const strokeWidth = ref(props.strokeWidth)const duration = ref(props.duration)const stagger = ref(props.stagger)const strokeColor = ref('#111111')
const name = ref(props.initial)const font = shallowRef<any | null>(null)const loading = ref(true)const animateKey = ref(0)const rootRef = ref<SVGSVGElement | null>(null)const isAnimating = ref(true)
const fontSize = 72
async function loadFont(url: string) { loading.value = true try { const buffer = await (await fetch(url)).arrayBuffer() font.value = opentype.parse(buffer) } catch (e) { console.error('Failed to load font', e) } finally { loading.value = false }}
onMounted(() => loadFont(props.fontUrl))
watch(() => props.fontUrl, async (url) => { await loadFont(url) animateKey.value++ replay()})
const letters = computed<{ d: string }[]>(() => { const f = font.value if (!f) return [] const text = name.value || '' const out: { d: string }[] = [] let x = 0 for (const ch of text) { if (ch === ' ') { x += f.getAdvanceWidth(' ', fontSize) continue } const glyph = f.charToGlyph(ch) const d = glyph.getPath(x, 0, fontSize).toPathData(2) if (d) out.push({ d }) x += glyph.advanceWidth * (fontSize / f.unitsPerEm) } return out})
const viewBox = computed(() => { const f = font.value if (!f || !name.value) return '0 -60 400 90' const width = f.getAdvanceWidth(name.value, fontSize) const ascender = (f.ascender / f.unitsPerEm) * fontSize const descender = (f.descender / f.unitsPerEm) * fontSize const pad = 16 return `${-pad} ${-ascender - pad} ${width + pad * 2} ${ascender - descender + pad * 2}`})
watch([name, duration, stagger], () => { animateKey.value++ replay()})
function replay() { isAnimating.value = false requestAnimationFrame(() => { void rootRef.value?.getBoundingClientRect() requestAnimationFrame(() => { isAnimating.value = true }) })}
let observer: IntersectionObserver | null = nullonMounted(() => { if (!rootRef.value) return observer = new IntersectionObserver((entries) => { for (const e of entries) { if (e.isIntersecting) replay() else isAnimating.value = false } }, { threshold: 0.4 }) observer.observe(rootRef.value)})onUnmounted(() => observer?.disconnect())
const cssVars = computed(() => ({ '--sig-duration': `${duration.value}s`, '--sig-stagger': `${stagger.value}s`, '--sig-delay': `${props.delay}s`,}))
function downloadSvg() { const svg = rootRef.value if (!svg) return const clone = svg.cloneNode(true) as SVGSVGElement clone.removeAttribute('style') const src = new XMLSerializer().serializeToString(clone) const blob = new Blob([`<?xml version="1.0" encoding="UTF-8"?>\n${src}`], { type: 'image/svg+xml' }) const url = URL.createObjectURL(blob) const a = document.createElement('a') a.href = url a.download = `${(name.value || 'signature').replace(/\s+/g, '-')}.svg` a.click() URL.revokeObjectURL(url)}</script>
<template> <div class="flex w-full flex-col items-center gap-4"> <div class="relative flex w-full items-center justify-center overflow-hidden rounded-xl bg-white p-6" style="min-height: 160px;" > <svg v-if="!loading && letters.length" ref="rootRef" :key="animateKey" class="signature h-[120px] w-full" :class="{ 'is-animating': isAnimating }" :viewBox="viewBox" preserveAspectRatio="xMidYMid meet" fill="none" :style="cssVars" > <path v-for="(letter, i) in letters" :key="i" :d="letter.d" fill="none" :stroke="strokeColor" :stroke-width="strokeWidth" stroke-linecap="round" stroke-linejoin="round" pathLength="1" :style="{ animationDelay: `calc(var(--sig-delay) + ${i} * var(--sig-stagger))` }" /> </svg> <div v-else-if="loading" class="text-[12px] text-neutral-400">Loading font…</div> <div v-else class="text-[12px] text-neutral-400">Type your name</div> </div>
<div class="grid w-full grid-cols-3 gap-3"> <label class="flex flex-col gap-1"> <span class="flex items-center justify-between text-[10px] font-medium uppercase tracking-wider text-muted-foreground"> Thickness <span class="tabular-nums text-foreground">{{ strokeWidth.toFixed(1) }}</span> </span> <input v-model.number="strokeWidth" type="range" min="0.4" max="6" step="0.1" class="sig-range" /> </label> <label class="flex flex-col gap-1"> <span class="flex items-center justify-between text-[10px] font-medium uppercase tracking-wider text-muted-foreground"> Speed <span class="tabular-nums text-foreground">{{ duration.toFixed(2) }}s</span> </span> <input v-model.number="duration" type="range" min="0.2" max="2" step="0.05" class="sig-range" /> </label> <label class="flex flex-col gap-1"> <span class="flex items-center justify-between text-[10px] font-medium uppercase tracking-wider text-muted-foreground"> Stagger <span class="tabular-nums text-foreground">{{ stagger.toFixed(2) }}s</span> </span> <input v-model.number="stagger" type="range" min="0" max="0.6" step="0.02" class="sig-range" /> </label> </div>
<div class="flex w-full items-center gap-2"> <label class="flex h-9 items-center gap-1.5 rounded-[8px] border border-border bg-muted/50 px-2 text-[10px] font-medium uppercase tracking-wider text-muted-foreground" title="Stroke color" > Ink <input v-model="strokeColor" type="color" class="h-5 w-5 cursor-pointer rounded border-0 bg-transparent p-0" /> </label> <input v-model="name" type="text" maxlength="24" placeholder="Your name" class="h-9 flex-1 rounded-[8px] border border-border bg-muted/50 px-3 text-[13px] text-foreground placeholder:text-muted-foreground/50 focus:bg-muted focus:outline-none focus:ring-1 focus:ring-ring" /> <button type="button" class="h-9 rounded-[8px] border border-border bg-background px-3 text-[12px] font-medium text-foreground hover:bg-muted" @click="replay" > Replay </button> <button type="button" class="h-9 rounded-[8px] bg-foreground px-3 text-[12px] font-medium text-background hover:opacity-90" @click="downloadSvg" > Download </button> </div> </div></template>
<style scoped>@keyframes draw { to { stroke-dashoffset: 0; }}
.sig-range { appearance: none; width: 100%; height: 4px; background: var(--border); border-radius: 999px; outline: none;}.sig-range::-webkit-slider-thumb { appearance: none; width: 14px; height: 14px; border-radius: 999px; background: var(--foreground); cursor: pointer; border: 2px solid var(--background); box-shadow: 0 1px 3px rgba(0,0,0,0.15);}.sig-range::-moz-range-thumb { width: 14px; height: 14px; border-radius: 999px; background: var(--foreground); cursor: pointer; border: 2px solid var(--background); box-shadow: 0 1px 3px rgba(0,0,0,0.15);}
.signature path { stroke-dasharray: 1 1; stroke-dashoffset: 1;}
.signature.is-animating path { animation: draw var(--sig-duration) ease forwards;}
@media (prefers-reduced-motion: reduce) { .signature path { animation: none; stroke-dashoffset: 0; }}</style>More effects

Stop recreating designs you've already seen
Join now and get 30% off your first year as an early supporter.
One email on launch day. That's it.
