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.js
022. 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

Purple gradient background

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.