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.
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".
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:
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 * lengthgap = 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.
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):
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.
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:
Two things worth noting:
- We don't clear the canvas. Each frame adds to what's already there, which is what makes the path build up over time.
- We track
lastDist. Without it, every frame would re-sample from0- 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 withprogress, 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 CSSfilter. 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:
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):
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:
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:
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:
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:
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.
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:
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.
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 callingmeasureTextevery frame. - Each glyph is stamped at a fixed spot and never moves once placed, so this is another purely additive animation. Same
lastIdx → targetdelta pattern from earlier, and noclearRectat 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:
- 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. - 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.
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.