Cobra Developers
Blog, Engineering

Animating a snake that underlines text with canvas

The short answer

The snake is drawn on a canvas laid over the page. Its head moves along a path sampled every two pixels, and its body is drawn through the trail of points the head has already visited. Range.getClientRects finds the last line of the title, so the snake can stop exactly beneath the text.

Unless your device asks for reduced motion, there is a snake under the title of this post. It came in from the left a moment after the page loaded, and it will sit there flicking its tongue for as long as you read. On the blog index it is livelier: point at a title and it slithers over to underline that one, taking the gap between the columns so it never crosses any text.

It is a small thing. It is also a tidy example of a technique worth knowing: an animated line that follows a path, drawn on a canvas, anchored to real text in the page.

The whole effect is one file of a little over 200 lines with no library. Like Planet Chase, our space runner where every planet is generated in code, it ships no image at all. The snake is a run of short stroked line segments, two ellipses for the head, and a dot for the eye.

How do you find where the last line of a heading ends?

To find the last line of wrapped text, create a Range over the element's contents and call getClientRects(). It returns one rectangle per line box, so the last rectangle is the last line. An element's own bounding box only gives you the full block, which is wrong whenever the text wraps.

This is the function that tells the snake where to go:

const lineOf = (el) => {
  const range = document.createRange();
  range.selectNodeContents(el);
  const rects = range.getClientRects();
  const r = rects[rects.length - 1] || el.getBoundingClientRect();
  const c = container.getBoundingClientRect();
  return { x0: r.left - c.left, x1: r.right - c.left, y: r.bottom - c.top + 5 };
};

It returns the left edge, the right edge, and a baseline five pixels under the text, all relative to the container the canvas covers. A long title that wraps onto two lines gets its underline under the second line only, and only as wide as the words on it.

The canvas itself is added by the script, positioned over the list with pointer-events: none so it never blocks a click. The stylesheet adds a little padding under each title so there is room for a snake.

How does the head follow a path?

The head follows a path that has been converted in advance into a list of points two pixels apart. Each frame, the head advances some number of points along that list. Straight segments and curves are reduced to the same samples, so the movement code never needs to know the shape.

Sampling a straight line is a division:

const STEP = 2; // pixels between path samples
const sampleLine = (a, b, out) => {
  const d = Math.hypot(b.x - a.x, b.y - a.y), n = Math.max(1, Math.round(d / STEP));
  for (let i = 1; i <= n; i++) out.push({ x: a.x + ((b.x - a.x) * i) / n, y: a.y + ((b.y - a.y) * i) / n });
};

Speed is not constant. The number of points consumed per frame depends on how many are left, so the snake starts quickly and eases in as it arrives:

const left = path.length - pi;
const speed = Math.min(10, Math.max(1.4, left * 0.085));
const to = Math.min(path.length, Math.floor(pi + speed));
for (let i = Math.floor(pi); i < to; i++) trail.push(path[i]);
pi += speed;

That one expression does the job of an easing function. Far from the target the head covers ten samples a frame, and close to it the speed falls toward a floor so it never stalls.

How does the body follow the head?

The body follows the head by remembering where the head has been. Every point the head passes is pushed onto a trail, and the body is drawn through the most recent stretch of that trail, as long as the snake. The body therefore takes every corner the head took, with no extra path logic.

const n = Math.min(trail.length, Math.ceil(L / STEP));
pts = [];
for (let i = 0; i < n; i++) pts.push(trail[trail.length - 1 - i]);

L is the snake's length, which is set to the width of the line it is heading for, with a minimum of 70 pixels. So the snake is always exactly as long as the underline it is about to become. The trail is trimmed to a little more than that length so it does not grow forever.

The slither is added on top. Each body point is pushed sideways, at right angles to the direction of travel, by a sine wave that moves along the body over time:

const env = Math.min(1, s / 26);         // the head holds steady, the wave builds behind it
const off = amp * env * Math.sin(s * 0.085 - time);
return { x: p.x + (nx / nl) * off, y: p.y + (ny / nl) * off };

The envelope keeps the head still and lets the wave grow behind it. The amplitude eases between two values: larger while travelling and small at rest, so a settled snake still breathes a little without making the underline hard to look at.

How do you route the snake so it never crosses the text?

The snake avoids text by travelling in the empty channel between the title column and the description column. To change rows it leaves along its current line, turns into that channel, runs up or down to the new row, and enters under the new title from the right. The corners are rounded.

The route is four points, handed to a function that rounds the corners:

const lane = side ? side.getBoundingClientRect().left - c.left - 34 : t.x1 + 60;
// ...
roundPath([head, { x: lane, y: head.y }, { x: lane, y: t.y }, { x: t.x0, y: t.y }], 34, next);

roundPath walks the polyline. Before each corner it stops short by a radius, samples a curve to the same distance past the corner, and carries on. The curve is a cubic Bezier with both control points on the corner itself. The radius shrinks when a segment is too short to fit it.

There is a special case for a title on the same line the snake is already on, where a straight slide is enough. And when the pointer leaves the list entirely, the snake exits along its current line, off whichever side its head is at, and the canvas is cleared.

How does it settle into an underline?

The snake settles by switching from the trail to the text. While it travels, the body comes from where the head has been. Once the head reaches the end of its path, the body is rebuilt each frame as a straight row of points under the title's last line, measured fresh every time.

if (phase === 'rest' && target) {
  const t = lineOf(target);                 // read live, so resizes and font swaps are followed
  pts = [];
  if (rev) for (let x = t.x0; x <= t.x1; x += STEP) pts.push({ x, y: t.y });
  else for (let x = t.x1; x >= t.x0; x -= STEP) pts.push({ x, y: t.y });
}

Measuring live is what keeps it honest. If the window is resized, or a web font arrives and the title reflows, the underline moves with the text on the next frame. The rev flag records which end the head is on, since the snake can arrive from either side.

Drawing is two passes over the same segments: a slightly wider stroke in our yellow-green as an outline, then the ink-coloured body on top, tapering toward the tail. The outline is why the snake stays visible when it crosses the yellow-green row it is underlining. At rest, the tongue flicks out briefly every 2.6 seconds.

What does it take to make an effect like this behave?

A decorative animation should cost nothing when it is not needed and should never get in the way. That means it loads only where it is used, stops when it is off screen, respects reduced motion, and has no effect on the page's layout or on clicks.

Here is what ours does:

  1. The script is loaded with a dynamic import() only on pages that contain a post list or a post heading.
  2. If the visitor prefers reduced motion, nothing is attached at all.
  3. The hover version on lists is attached only on devices that can hover.
  4. On a post page, the snake waits for fonts to be ready before measuring the title.
  5. An IntersectionObserver stops the frame loop while the container is off screen.
  6. Keyboard focus moves the snake the same way the pointer does.

One trade-off we accepted: while the snake is visible, the frame loop keeps running so the body can sway and the tongue can flick. If you want a strictly idle page, stop the loop at rest and draw a still frame.

For more canvas work, see the force-directed project graph. The hero at the top of the homepage takes the opposite approach and runs on the graphics card, described in our WebGPU shader post.

Frequently asked questions

Why use canvas for this and not SVG?

Either can work. SVG has path animation built in, and a stroke can be revealed along a path with dash offsets. We wanted a body whose width tapers and whose shape wiggles every frame, which is simpler to draw directly on a canvas than to express as changing SVG attributes.

Does an overlay canvas hurt accessibility?

Not if it is purely decorative and marked that way. Ours has aria-hidden set, ignores pointer events, and adds no content. The titles underneath remain ordinary links that work with a keyboard and a screen reader, with or without the animation.

Can the same technique underline any text?

Yes. The measuring function works on any element containing text. The routing is specific to our two-column list, so a different layout needs its own rule for where the empty space is.

How do you keep the lines crisp on sharp screens?

The canvas is sized by the device pixel ratio, capped at two, and the drawing context is scaled to match.

If you want a site with details people remember, tell us what you are building.

Who wrote this

The Cobra Developers team. We are a software studio in Arizona that builds and runs its own products and builds websites, web apps, and automation for clients. This post draws on our work on Planet Chase. Everything here comes from projects we shipped; see all 30 of them.

guest@cobra: ~