Skip to the article
henaifuos.com
Web

Draw a route with SVG stroke-dashoffset

A path that draws itself is one of the cheapest, cleanest animations on the web: no library, no canvas, a few lines of CSS. This guide builds one from the smallest version up to a colored route with a dot riding its head.

By Soufiane H. 6 min read

One dash as long as the whole path, slid out of view and back.

The trick

A stroke can be dashed. stroke-dasharray sets the length of each dash and gap, and stroke-dashoffset slides that pattern along the path. Make one dash as long as the whole path, push it out of view with the offset, then animate the offset back to zero. The line appears to draw itself.

The only awkward part is knowing the length of the path. SVG has a shortcut: set pathLength and the browser treats the path as if it had that length, so every dash value is relative to it. With pathLength="1", a dash of 1 is the whole path.

The smallest version

One path, a few declarations and a keyframe. No script.

HTMLroute.html
<svg viewBox="0 0 560 360" role="img" aria-label="A route from left to right">
  <path class="route" pathLength="1"
        d="M40 300C120 80 240 80 320 200S480 330 520 100" />
</svg>
CSSroute.css
.route {
  fill: none;
  stroke: #d4f463;
  stroke-width: 8;
  stroke-linecap: round;
  stroke-dasharray: 1 2;
  stroke-dashoffset: 1;
  animation: draw 2.8s cubic-bezier(0.65, 0, 0.35, 1) forwards;
}

@keyframes draw {
  to { stroke-dashoffset: 0; }
}

The dasharray is 1 2: a dash of one path length, then a gap of two. Because the gap is longer than the path, an offset of 1 hides the whole line, and a stray dot that some browsers paint at the end of a hidden path with round caps stays away.

One clock for line and dot

A dot that rides the head of the line must follow exactly the same timing. Two separate animations drift apart, so drive both from one clock. getPointAtLength returns the coordinates at any distance along the path.

JavaScriptroute.js
const route = document.querySelector('.route');
const dot = document.querySelector('.dot');
const length = route.getTotalLength();
const duration = 2800;
const ease = (t) => (t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2);
let startTime;

function frame(now) {
  startTime ??= now;
  const t = Math.min(1, (now - startTime) / duration);
  const progress = ease(t);

  route.style.strokeDashoffset = String(1 - progress);
  const point = route.getPointAtLength(progress * length);
  dot.setAttribute('transform', `translate(${point.x} ${point.y})`);

  if (t < 1) requestAnimationFrame(frame);
}

requestAnimationFrame(frame);

getTotalLength() still returns the real length in user units, so the dot lands on the line even though the dashes use the normalized length. The script only touches stroke-dashoffset and one transform, which keeps the work on the cheap side of the browser's rendering pipeline.

Color the route by segment

Stack several copies of the same path, each showing one slice, and reveal them together with a mask. Each segment is a dash of the slice's length, offset by minus its start. A thick white stroke inside the mask uncovers every segment from left to right as the line travels.

HTMLsegments.html
<svg viewBox="0 0 560 360" aria-hidden="true">
  <defs>
    <mask id="reveal" maskUnits="userSpaceOnUse"
          x="0" y="0" width="560" height="360">
      <path class="reveal" pathLength="1"
            d="M40 300C120 80 240 80 320 200S480 330 520 100" />
    </mask>
  </defs>
  <g mask="url(#reveal)">
    <path class="segment" pathLength="100"
          style="stroke: #5aa1f1; stroke-dasharray: 10 200; stroke-dashoffset: 0"
          d="M40 300C120 80 240 80 320 200S480 330 520 100" />
    <path class="segment" pathLength="100"
          style="stroke: #cbfd50; stroke-dasharray: 50 200; stroke-dashoffset: -10"
          d="M40 300C120 80 240 80 320 200S480 330 520 100" />
    <path class="segment" pathLength="100"
          style="stroke: #e9366e; stroke-dasharray: 40 200; stroke-dashoffset: -60"
          d="M40 300C120 80 240 80 320 200S480 330 520 100" />
  </g>
</svg>
CSSsegments.css
.reveal {
  fill: none;
  stroke: #fff;
  stroke-width: 28;
  stroke-dasharray: 1 2;
  stroke-dashoffset: 1;
  animation: draw 2.8s cubic-bezier(0.65, 0, 0.35, 1) forwards;
}

.segment {
  fill: none;
  stroke-width: 8;
  stroke-linecap: round;
}

Reduced motion and no JavaScript

People who ask their system for less motion should get the finished route straight away. And visitors or crawlers without JavaScript should never see an empty page. Add a js class to the root element with a one-line script, and apply the hidden starting state only under it.

CSSroute.css
.js .route {
  stroke-dasharray: 1 2;
  stroke-dashoffset: 1;
}

@media (prefers-reduced-motion: reduce) {
  .js .route {
    animation: none;
    stroke-dashoffset: 0;
  }
}

For the scripted version, check the same preference before starting the clock and jump to the end state.

JavaScriptroute.js
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
  route.style.strokeDashoffset = '0';
  const end = route.getPointAtLength(length);
  dot.setAttribute('transform', `translate(${end.x} ${end.y})`);
} else {
  requestAnimationFrame(frame);
}

Keep it cheap and readable

  • Animate the cheap properties. Stick to stroke-dashoffset, transform and opacity. Avoid animating the path data, widths or positions.
  • Name the graphic or hide it. Give the SVG role="img" and an aria-label when it carries meaning, or aria-hidden="true" when it is decoration and the facts sit in nearby text.
  • Put the information in HTML. Search engines and screen readers read text, not strokes. The route is a flourish; the distance, the time and the title belong in the markup.
  • Play it once. A draw that plays once is easier on people and on batteries than one that repeats forever.

In short

Set pathLength, hide the line with a dash that is longer than the path, animate the offset to zero and drive any rider from the same clock. It is a handful of lines, it respects reduced motion, and it leaves your content in the HTML where search engines and screen readers find it. The line drawings on this site use exactly this approach.

Next: keeping screens testable with MVVM-C, SwiftUI and Combine.

About the author

Soufiane H. is an iOS and Flutter developer in Casablanca who designs, builds and publishes mobile apps for iOS and Android.

Keep reading