Canvas Path Animations using SVG

Draw anything along an arbitrary path by combining the Canvas API with two little-known SVG methods - getTotalLength() and getPointAtLength().

Canvas gives us full control over pixels. SVG paths give us math. Combine them and we can draw anything along an arbitrary curve - with no animation library in sight.

Code Playground

That whole effect is ~50 lines of code built on two SVG methods most people never touch. Let's build up to it.

The two SVG methods we need

Every <path> element in SVG exposes a small API for querying its geometry:

  • getTotalLength() - the length of the path in user units.
  • getPointAtLength(distance) - the {x, y} at any distance along the path.

That's it. Everything else in this post is a variation on "walk along the path and draw something at each point".

getPointAtLength(0.0/ 0.0)

Drag the slider to sample any point on the curve. Note that we're querying an SVG element, but nothing forces us to actually render that SVG - we can keep it hidden and use it purely as a source of coordinates.

Here's the minimal setup:

Code Playground

Now let's turn this into motion.

Techniques

There are two building blocks that show up in almost every path animation, and both are useful on their own. The first draws the path itself; the second lets you place anything you want along it.

Technique 1 - Revealing a path with setLineDash

The simplest path animation isn't animation at all - it's a dash pattern that grows. Canvas's setLineDash([visible, gap]) lets you specify a repeating on/off stroke pattern. If we set:

  • visible = progress * length
  • gap = length

…then as progress goes from 0 to 1, the visible dash grows from nothing to the whole path, while the gap stays large enough that we never see a second dash.

ctx.setLineDash([0, 0]);
visible dashinvisible gap

That's the trick. One line of code, animated over time (the fit() helper just sizes the canvas backing store to its CSS box using devicePixelRatio and scales the drawing so path coords 260×140 land sharply in the middle - same in every demo below):

Code Playground

This is great for a clean "drawing itself" effect - but every point on the stroke looks the same. To vary the appearance along the path, we need to actually visit each point.

Technique 2 - Traversing a path with getPointAtLength

Instead of stroking the whole path at once, walk along it in small increments and draw something at each sampled point. That "something" can be anything: a circle, a square, a particle, a piece of text.

segmentLength =12px(0 samples)

Drag the slider to change how frequently we sample. At 2px we get a smooth continuous line of dots; at 40px we get a dashed necklace. The technique is the same either way - only the sampling step changes.

In an animation, we sample from the last frame's position up to the current progress:

Code Playground

Two things worth noting:

  1. We don't clear the canvas. Each frame adds to what's already there, which is what makes the path build up over time.
  2. We track lastDist. Without it, every frame would re-sample from 0 - wasteful, and it would fight against the accumulated paint on the canvas.

Putting it together - the gradient path

The gradient path animation at the top of this post layers two canvases on top of each other:

  • A gradient fill drawn as a chain of filled circles - one ctx.arc(x, y, THICKNESS / 2, 0, 2π) at each sample point, each with its own linear gradient. The hue rotates with progress, which is why the whole thing shifts color as it draws.
  • A soft shadow rendered on a second canvas underneath: the full path stroked in black with setLineDash([target, length]) so only the traversed portion is visible, then blurred via a CSS filter. This gives us a genuinely soft, uniform glow that shadowBlur alone can't match.

Here's the same idea on a simpler path, so you can see it end-to-end without the hero's transform math getting in the way:

Code Playground

The gradient recomputes each frame with delta folded into the hue - that's what rotates the palette as the stroke advances. Because each circle's gradient is centered on its own sample point, the same vertical-gradient definition produces a smooth banded look along the entire path.

A word on performance

There's a tension baked into this technique: the more points you sample, the smoother the result - and the more work each frame does. Get it wrong and the animation jitters.

requestAnimationFrame fires roughly every 16.67ms on a 60Hz display. Everything your tick function does - sampling points, computing gradients, stroking segments, applying shadows - has to fit inside that budget. Go over and the browser drops frames; the animation stops feeling continuous and starts feeling slow, even though it's technically running.

The gradient path animation above stays smooth because it visits every point on the path once across the whole animation - each frame samples only from the last frame's position up to the current one, so per-frame work stays roughly flat.

That trick relies on one thing: everything already drawn is static. Once a pixel is painted, it stays. But plenty of effects don't have that luxury. The comet trail at the end of this post fades the trail behind its head as the head moves forward, and its sparkles drift and fall away one frame at a time. Anything where the past has to keep changing forces you to clearRect and repaint every frame - and that's when per-frame cost stops being a theoretical concern and starts eating your budget.

The naïve alternative - redrawing the entire revealed length on every frame with a fine stride - is where things get expensive. The first frame of that approach does almost no work: a handful of getPointAtLength calls, a handful of tiny strokes. The last frame redraws every sample from the start of the path to the end, on top of everything the previous ~180 frames deposited. Frame work grows linearly with progress, and it's the tail of the animation where the frame budget blows up - the reveal looks fine right up until it hitches in the last stretch.

Here's the difference on a longer path - two renderers side by side, the same reveal drawn two ways. Hit Play to run one cycle (the naive version can hitch, which is the whole point):

Naive · getPointAtLength every frame + 0.4px stride + per-segment strokedraw 0.0ms · frame 0.0ms · 0 fps
Optimized · precomputed samples + 3px stride + batched delta strokedraw 0.0ms · frame 0.0ms · 0 fps

The top canvas calls getPointAtLength every frame, redraws the entire revealed length with a sub-pixel stride, and issues a fresh beginPath/stroke for every one of those tiny segments. The bottom one precomputes the samples, walks a wider stride, and batches the whole delta into a single stroke. Identical output - dramatically different frame times.

Here's what actually moves the needle:

Precompute the path samples

getPointAtLength() isn't free - to answer the query the browser has to arc-length-parameterize the path (subdivide the curve, integrate lengths), and the result isn't cached across calls. If your visual doesn't require picking a new sampling density at runtime, do it once up front:

js
const samples = [];
for (let d = 0; d <= length; d += 1) {
  samples.push(path.getPointAtLength(d));
}

Then the animation loop turns into samples[i] - an array read, essentially free. This is the single biggest win, and it's why my first instinct - "draw more points per frame" - didn't help me either. The per-point cost was the bottleneck, not the loop overhead. Moving that cost out of the loop entirely is what changes the shape of the problem.

Batch strokes when the style is uniform

Every beginPath() + stroke() pair costs a Canvas state flush. If you're drawing a run of segments that share the same strokeStyle and lineWidth, batch them:

js
ctx.beginPath();
for (let i = last; i < target; i++) {
  const p = samples[i], next = samples[i + 1];
  ctx.moveTo(p.x, p.y);
  ctx.lineTo(next.x, next.y);
}
ctx.stroke();

The catch: the gradient path animation can't batch, because each segment gets its own translated gradient. If you drop the gradient - say, for a solid-color reveal - batching gives you an order-of-magnitude improvement on long paths.

Shadows are expensive

shadowBlur runs a real gaussian convolution over every stroke you draw, per frame. On the gradient path animation that's the single most costly thing in the loop. If your effect looks almost as good without it, drop it. If you need it, keep shadowBlur low (single digits) and consider drawing the "glow" as a separate, pre-blurred stroke on a second, lower-resolution canvas layered underneath.

Widen the sampling stride

A 1-pixel stride is overkill for most stroke widths. At thickness = 12, sampling every 3-4 pixels with lineCap: 'round' is visually indistinguishable from every 1 pixel - and does a third of the work. Let the stroke caps hide the gaps. Test with your actual thickness before deciding on the stride.

Only draw the delta (when you can)

The dash-reveal, point-traversal, gradient-path, dust-trail, and text-on-a-path demos all use the last → target pattern instead of redrawing from 0 each frame. When it applies, it looks like a minor detail but it's what turns O(length × frames) into O(length) over the whole animation.

The catch is the one from the top of this section: it only holds when previously-painted pixels stay valid. The comet trail below is the one demo in this post that has to clear-and-repaint every frame, and everything else on this list - precomputation, batching, wider strides, no shadows - is what keeps it inside budget.

The precomputation hack in particular composes with everything else - once you have the samples in an array, you can iterate them at any stride, sample the same array from multiple animation states, or even share it across multiple paths that reuse the same shape.

Respect prefers-reduced-motion

Every demo on this page autoplays. On a real site, gate that on the user's OS-level motion preference:

js
const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;

If it's on, skip the animation and paint the final frame directly - same visual result, no drawing sequence. Costs you nothing and it's the kind of thing motion-sensitive users notice immediately.

Where to take it next

Every effect in this post reduces to the same loop:

js
for (let d = last; d < progress * length; d += step) {
  const point = path.getPointAtLength(d);
  // draw whatever you want at `point`
}

Once you have a coordinate at every step, the visual is entirely up to you.

Particles

Instead of drawing a stroke, drop a small "anchor" dot at each sample and scatter a few extra dots randomly around it - each one placed at a random angle and a random distance within some radius r. As the progress sweeps forward, you get a soft, dust-like trail that hugs the path instead of tracing it exactly.

Code Playground

The core loop is unchanged. The only new pieces are the random offset per particle and a deterministic seed so the offsets stay put frame to frame:

js
for (const p of samples.slice(0, visibleCount)) {
  drawDot(p.x, p.y);
  for (let j = 0; j < N; j++) {
    const angle = rand(i, j) * Math.PI * 2;
    const dist = rand(i, j + 1) * r;
    drawDot(p.x + Math.cos(angle) * dist,
            p.y + Math.sin(angle) * dist);
  }
}

Text on a path

Same idea, different payload. Walk the path one character at a time: for each glyph, take its width, sample two points on the path (d and d + width), compute the angle between them, and stamp the character rotated to match.

Code Playground

A few notes on this one:

  • We measure the widths up front and store cumulative offsets, so the animation loop just reads offsets[i] instead of calling measureText every frame.
  • Each glyph is stamped at a fixed spot and never moves once placed, so this is another purely additive animation. Same lastIdx → target delta pattern from earlier, and no clearRect at all - redrawing already-placed characters every frame would waste work without changing the result.
  • The rotation itself handles any angle correctly - it's the layout that's fragile. On sections of high curvature, adjacent glyphs diverge and start to overlap or splay; on near-vertical sections, characters end up sideways or upside-down (technically correct, rarely legible). If your path has sharp bends, widen the spacing between characters or fall back to shorter strings.

The comet trail in the next section is where clearing every frame actually becomes necessary - because there, the pixels that were already drawn have to change on every frame (the trail fades, the sparkles drift). Without a clearRect, the head's previous positions would leave a solid, non-fading streak - a literal ghost trail behind the current position - and the sparkles would smear into hazy lines instead of falling away as individual points.

Comet trail

Now for a fun one. Draw a bright dot at the current position on the path, and behind it, a fading trail that curves along the path. Then scatter little sparkles that detach from the head and fall away with some drift.

Two moving parts:

  1. The trail is just N samples behind the head at fixed distance intervals - progressively smaller and more transparent. Because the trail lives on the path, we can sample it fresh every frame from path.getPointAtLength(headDist - offset) and never store history.
  2. The sparkles each have their own position, velocity, and life, so they need to persist between frames. We keep them in an array, step their physics on every tick, and discard the ones whose life hits zero.

Because the sparkles need real state anyway, there's no reason to lean on the "translucent-rect fade" trick people sometimes use for trails. We just clear the canvas cleanly every frame and redraw everything from the current state.

Code Playground

The takeaway: the trail is a function of the path, so it needs no per-frame state; the sparkles are a function of time, so they do. Any effect that shakes loose from the path - dust, embers, debris, particles reacting to gravity or wind - falls in the second category, and once you have that array around, adding more of them costs almost nothing.

Summary

The two SVG methods stay the same across every example in this post. Only what you draw changes - a stroke, a gradient, a cloud of particles, a string of characters. Anything you can put at a (x, y) you can put on a path.

Share this lovely stuff with your besties!

A newsletter for front-end web developers

Stay up-to-date with my latest articles, experiments, tools, and much more!

Issued monthly (or so). No spam. Unsubscribe any time.