Nocturne

Command Palette

Search for a command to run...

SVG Stroke Animations with Anime.js v4

June 5, 2026 · 8 min read · by Abhishek

Anime.js v4 introduced a dedicated SVG module with createDrawable — a clean API for animating the stroke-dashoffset trick without manually computing path lengths.

The stroke-dashoffset trick

SVG path drawing works by manipulating stroke-dasharray and stroke-dashoffset. When dasharray equals the total path length and dashoffset equals the same, the stroke is invisible. Reducing dashoffset to zero reveals it left-to-right.

Anime.js abstracts this entirely. You pass a draw property as "start end" where both values are progress ratios between 0 and 1:

import { createDrawable, createTimeline } from "animejs"
 
const path = document.querySelector("path")
const d = createDrawable(path)
 
createTimeline({ loop: true })
  .add(d, { draw: ["0 0", "0 1", "1 1"], duration: 4800, ease: "linear" })

The three keyframes produce:

  • "0 0" → invisible (head and tail both at position 0)
  • "0 1" → head sweeps to the end (stroke is fully drawn)
  • "1 1" → tail catches up (stroke disappears from the left)

Per-glyph stagger

For a word heading, animating one path per letter with stagger creates a wave effect:

import { stagger } from "animejs/utils"
 
const glyphs = Array.from(document.querySelectorAll(".draw-path"))
const drawables = glyphs.map(p => createDrawable(p))
 
createTimeline({ loop: true, loopDelay: 800 })
  .add(drawables, {
    draw: ["0 0", "0 1", "1 1"],
    duration: 4800,
    delay: stagger(200, { start: 300 }),
    ease: "inOutQuad",
  })

Each glyph starts 200 ms after the previous, and the first glyph waits 300 ms — so the animation feels like it sweeps left-to-right across the word.

The ghost layer

Nocturne renders two SVG <g> groups per heading: a static ghost layer at low opacity (so the text shape is always visible) and the animated draw layer on top:

<g opacity="0.45" fill="none" stroke="var(--highlight)" stroke-width="1">
  <!-- ghost paths, always visible -->
</g>
<g fill="none" stroke="var(--primary)" stroke-width="1.5" class="draw-paths">
  <!-- animated paths -->
</g>

This gives the animation context — the viewer understands the full word even while strokes are mid-draw.

IntersectionObserver trigger

Animations should fire when the element enters the viewport, not on page load:

function observeOnce(selector: string, callback: (el: Element) => void) {
  const observer = new IntersectionObserver((entries) => {
    for (const entry of entries) {
      if (!entry.isIntersecting) continue
      callback(entry.target)
      observer.unobserve(entry.target)
    }
  }, { threshold: 0.15 })
 
  document.querySelectorAll(selector).forEach(el => observer.observe(el))
}

The animation starts only once, 15% into the viewport, and the observer immediately disconnects so it doesn’t fire again on scroll-back.

This work by Abhishek is licensed under CC BY 4.0 .

Published June 5, 2026.