I wanted my landing page to feel a bit more personal, so I added a hand-drawn signature that animates itself — like watching someone sign their name in real time. Here’s the full process from creating the SVG to the final implementation, including the LLM prompt I used to build it.

Step 1: Creating the Signature SVG

Drawing the Signature

I used DocHub — a free document signing tool — to draw my signature. Any tool that lets you sign with a mouse/trackpad/stylus and export as SVG works. Some alternatives:

  • DocHub (what I used) — sign a PDF, then export/download the signature as SVG
  • SVG signature pad libraries — like signature_pad by Szymon Nowak, which can export directly to SVG
  • iPad/tablet apps — draw in any app, export as SVG
  • Figma/Illustrator — draw with the pen tool, export as SVG

Why the Export Format Matters

The key requirement is that the SVG must use stroked paths (fill="none", stroke="black"), not filled shapes. DocHub’s export is perfect for this because it outputs the signature as a series of small cubic bezier <path> segments with round line caps:

<path d="M 54.430,55.914 C 51.210,54.968 51.238,54.889 48.047,53.863"
  stroke-width="4.818" stroke="black" fill="none" stroke-linecap="round" />

My signature SVG had ~120 of these small path segments plus a <circle> element for the dot on the “i”. Each segment is a short curve — the tool captures your pen movement as many small strokes rather than one continuous path. This actually works in our favor for the animation, because we can animate each segment sequentially.

If your SVG has filled shapes instead of strokes (common with some export tools), you’d need to convert them to stroked paths first, which is more complex. Stick with a tool that gives you stroke-based output.

Step 2: The LLM Prompt

I used Claude to implement the entire thing. Here’s roughly the prompt I gave it, which you can adapt for your own site:

Add an animated SVG signature that draws itself on the landing page. The signature SVG contains stroked paths (fill="none", stroke="black", stroke-linecap="round") with ~120 small path segments and a circle element. The draw-on animation should use CSS stroke-dasharray/stroke-dashoffset with sequential delays calculated proportionally from each segment’s length.

Requirements:

  • All paths get class signature-path, the circle gets class signature-dot
  • Container div with class signature-container
  • Replace hardcoded stroke="black" with stroke="currentColor" for theme support
  • Use the exact SVG content from my exported file
  • The inline script should: calculate each path’s getTotalLength(), set dasharray/dashoffset, compute cumulative delays proportional to segment length (total animation ~2.5s), set CSS custom properties (—delay, —duration, —easing), then add .animate class to trigger
  • Paths start at opacity: 0 until JS sets them up
  • Only show on the landing page (conditional render on index slug)
  • SPA compatible (listen to nav event, not DOMContentLoaded)

The key details in this prompt:

  • Specifying the technique (stroke-dasharray/stroke-dashoffset) — this avoids the LLM guessing at approaches
  • Proportional timing — without this, you’d get uniform timing which looks robotic
  • currentColor — dark mode support for free
  • opacity: 0 initial state — prevents flash of unstyled content before JS runs
  • SPA compatibility — critical for Quartz which uses client-side navigation

If you’re using a different static site generator (Astro, Next.js, Hugo, etc.), adjust the last two points to match your framework’s routing/lifecycle.

Step 3: How the Animation Works

The Core Trick: stroke-dasharray

This is a well-known SVG/CSS trick, but it’s worth understanding:

  1. stroke-dasharray defines a dash pattern. Set it to the path’s total length → one dash that covers the entire path
  2. stroke-dashoffset shifts where the dash starts. Set it to the same length → the dash is shifted completely off, path invisible
  3. Animate stroke-dashoffset to 0 → the dash slides into view, path “draws” itself
@keyframes draw {
  to {
    stroke-dashoffset: 0;
  }
}

The browser method path.getTotalLength() returns the exact pixel length of any SVG path. This is essential because each segment has a different length, and the dasharray value must match exactly.

Sequencing ~120 Segments

With so many path segments, each one needs to animate in order with no gaps. The approach:

  1. Loop through all paths, call getTotalLength() on each, store the lengths
  2. Sum up the total length across all segments
  3. For each segment, calculate its proportional duration: (segmentLength / totalLength) * TOTAL_DURATION
  4. Track a cumulative delay — each segment starts right when the previous one ends
const TOTAL_DURATION = 2.5 // seconds
const EASE = "cubic-bezier(0.4, 0, 0.2, 1)"
 
let totalLength = 0
const lengths: number[] = []
paths.forEach((path) => {
  const len = path.getTotalLength()
  lengths.push(len)
  totalLength += len
})
 
let cumulativeDelay = 0
paths.forEach((path, i) => {
  const len = lengths[i]
  const duration = (len / totalLength) * TOTAL_DURATION
  path.style.strokeDasharray = `${len}`
  path.style.strokeDashoffset = `${len}`
  path.style.setProperty("--delay", `${cumulativeDelay}s`)
  path.style.setProperty("--duration", `${duration}s`)
  path.style.setProperty("--easing", EASE)
  cumulativeDelay += duration
})

This proportional timing is what makes it look natural — longer strokes take more time, short flicks are quick. Without it (i.e., uniform duration per segment), the animation would look mechanical and wrong.

The CSS

Paths start invisible and only animate once JS has set everything up and added the .animate class:

.signature-path, .signature-dot {
  opacity: 0;
}
 
.signature-container.animate .signature-path {
  animation:
    draw var(--duration) var(--easing) var(--delay) forwards,
    fade-in 0.01s var(--delay) forwards;
}
 
.signature-container.animate .signature-dot {
  animation: fade-in 0.15s var(--delay) forwards;
}
 
@keyframes draw {
  to { stroke-dashoffset: 0; }
}
 
@keyframes fade-in {
  to { opacity: 1; }
}

The fade-in animation is a near-instant (0.01s) opacity flip that happens at the same --delay as the draw. Without it, you’d see a brief flash of all paths at once before the draw animation starts, because CSS stroke-dashoffset only hides the stroke visually — the element itself would still be rendered at full opacity.

The dot on the “i” gets a gentler 0.15s fade-in at roughly the midpoint of the animation (since the dot appears in the middle of the signature).

Step 4: Theme Support

The original SVG had stroke="black" on every path and fill="black" on the circle. Replacing both with currentColor makes the signature automatically follow whatever text color your CSS theme defines:

<!-- Before -->
<path stroke="black" fill="none" ... />
<circle fill="black" ... />
 
<!-- After -->
<path stroke="currentColor" fill="none" ... />
<circle fill="currentColor" ... />

Zero JS logic needed for dark mode. The signature just inherits the text color.

Step 5: Quartz-Specific Integration

If you’re using Quartz, here’s the component structure:

File structure

quartz/components/
  Signature.tsx              # Component with inline SVG
  styles/signature.scss      # Animation styles
  scripts/signature.inline.ts # Runtime animation setup
  index.ts                   # Add export here

Component pattern

Quartz components use Preact (class not className), and attach styles/scripts as static properties:

const Signature: QuartzComponent = (_props: QuartzComponentProps) => {
  return (
    <div class="signature-container">
      <svg ...>
        {/* paste all your paths here with class="signature-path" */}
        {/* circle with class="signature-dot" */}
      </svg>
    </div>
  )
}
Signature.css = style
Signature.afterDOMLoaded = script

The SVG is rendered inline (not as an <img> or <object>) so the browser has direct DOM access to each <path> element. This is required for getTotalLength() to work and for the CSS custom properties to be set on each element.

Conditional rendering (index page only)

In quartz.layout.ts:

afterBody: [
  Component.ConditionalRender({
    component: Component.Signature(),
    condition: (page) => page.fileData.slug === "index",
  }),
],

SPA navigation

Quartz uses client-side routing, so DOMContentLoaded only fires once. The script listens to Quartz’s nav event instead, which fires on every page transition:

document.addEventListener("nav", () => setupSignature())

This means the animation replays every time you navigate back to the landing page — which is the behavior you want.

Adapting for Other Frameworks

The core technique (dasharray + proportional delays) works anywhere. Here’s what changes per framework:

  • Plain HTML — put the script in a <script> tag, use DOMContentLoaded
  • React/Next.js — use a useEffect hook, refs for the container, currentColor works the same
  • Astro — use a <script> tag in an .astro component, or a framework component
  • Hugo — inline the SVG in a partial, add the script to a <script> tag

The SVG must be inlined in the HTML (not loaded as an image) for the animation to work.

Result

The signature draws itself over ~2.5s when you land on the homepage. It feels organic because the timing is proportional to each stroke’s length — fast flicks are fast, long curves are slow. The dot on the “i” fades in at roughly the right moment. Dark mode just works.

Small detail, but it makes the page feel less like a static site and more like something someone actually made.