Aduok Code

Off-Track Loader Animation — SVG Stroke Animation with Verlet-Inspired Jolt Physics

Introduction

In this article we build an Off-Track Loader — a pure SVG and CSS loading spinner that looks alive without a single line of JavaScript. A single complex SVG path traces a figure-of-eight route that weaves off and back around a circular ring. Two CSS keyframe animations run simultaneously: spiral advances a stroke-dashoffset to make the arc travel around the path, and jolt applies small translate offsets to the whole element to simulate the reactive momentum of a physical object being tugged. The result is a loader that feels tactile rather than mechanical. No JS, no libraries, no build step.

The gradient running along the stroke cycles through purple, pink, and orange — a warm tonal arc that reads well on both light and dark backgrounds. Dark mode is handled entirely via a prefers-color-scheme media query that swaps two CSS custom properties: --surface for the page background and --ink for the track ring colour.

What you will learn

How stroke-dasharray and stroke-dashoffset combine to animate a partial stroke along any SVG path — how a linearGradient applied via a gradientUnits="userSpaceOnUse" or objectBoundingBox gradient paints the stroke — how to write a jolt keyframe that mimics physical reaction using non-uniform translate offsets — how to rename CSS variables and class names for maintainability — how to expose an SVG loader to screen readers with role="img" and aria-label.

How stroke-dashoffset Animation Works

SVG strokes can be broken into a dash pattern defined by stroke-dasharray. A value of 44 1111 means: draw 44px of stroke, then skip 1111px. Because the total path length is roughly 1155px, this produces a single short arc of visible stroke with a long invisible gap behind it. Animating stroke-dashoffset shifts the starting position of this pattern along the path — so the visible arc appears to travel the full off-track route.

stroke-dashoffset is the closest CSS gets to a progress counter for path drawing — and it is GPU-compositable on the transform/opacity fast path in every modern browser.

The spiral keyframe runs from offset 10 (nearly at the start) through 295 at the 25% mark — a deliberate ease-in — then to 1165 at 100%, slightly past the total path length to ensure the arc completes its loop cleanly. The timing function cubic-bezier(0.42, 0.17, 0.75, 0.83) creates a subtle acceleration-deceleration that prevents the arc from feeling robotic.

01
Part One
HTML & SVG Structure

Step 1 — Marking Up the SVG

The Off-Track Loader is a single <svg> element with a viewBox="0 0 128 128", sized to 8em × 8em in CSS so it scales with the root font size. Inside it there are exactly three elements: a <defs> block containing the gradient, a <circle> for the static track ring, and a <path> for the animated off-track strand. The <svg> carries role="img" and aria-label="Loading" for screen readers.

HTML
<svg
  class="coil"
  viewBox="0 0 128 128"
  xmlns="http://www.w3.org/2000/svg"
  role="img"
  aria-label="Loading"
>
  <defs>
    <linearGradient id="coil-gradient" x1="0" y1="0" x2="1" y2="1">
      <stop offset="0%"   stop-color="hsl(270, 85%, 65%)" />
      <stop offset="50%"  stop-color="hsl(330, 90%, 60%)" />
      <stop offset="100%" stop-color="hsl(20,  95%, 60%)" />
    </linearGradient>
  </defs>

  <circle class="coil-track" r="56" cx="64" cy="64" />
  <path   class="coil-strand" d="M92,15.492S78.194,4.967 ... a56,56,0,1,0,56,0Z" />
</svg>
02
Part Two
Multi-Stop Gradient

Step 2 — Painting the Stroke with a Three-Stop Gradient

SVG stroke accepts a url(#id) reference to a gradient defined in <defs>. A linearGradient with x1="0" y1="0" x2="1" y2="1" runs diagonally from top-left to bottom-right across the element bounding box. Three stops — purple at 0%, pink at 50%, orange at 100% — create a warm tonal sweep. Because the off-track path curves around the full viewBox, different segments of the path sample different gradient positions, producing a natural colour shift as the arc travels.

SVG
<linearGradient id="coil-gradient" x1="0" y1="0" x2="1" y2="1">
  <stop offset="0%"   stop-color="hsl(270, 85%, 65%)" />  <!-- purple -->
  <stop offset="50%"  stop-color="hsl(330, 90%, 60%)" />  <!-- pink   -->
  <stop offset="100%" stop-color="hsl(20,  95%, 60%)" />  <!-- orange -->
</linearGradient>
03
Part Three
The Off-Track Path & Ring

Step 3 — The Track Ring and Off-Track Path

The track ring is a plain <circle> with r="56", centred at (64, 64). Its stroke is set in CSS via .coil-track to a low-opacity ink colour that adapts in dark mode. It provides a visual guide rail that the off-track strand appears to repeatedly leave and return to.

The off-track strand path is a complex figure-of-eight that repeatedly crosses and escapes the circle boundary — which is the origin of the "off-track" name. It has a total path length of roughly 1155px — long enough that a stroke-dasharray of 44 1111 leaves only one small arc visible at any time. The path is intentionally longer than the circle circumference so the arc can make a full visual lap without the animation needing to loop at an awkward visible seam.

SVG
<circle
  class="coil-track"
  r="56" cx="64" cy="64"
/>

<path
  class="coil-strand"
  d="M92,15.492S78.194,4.967,66.743,16.887
     c-17.231,17.938-28.26,96.974-28.26,96.974
     L119.85,59.892l-99-31.588,57.528,89.832
     L97.8,19.349,13.636,88.51l89.012,16.015
     S81.908,38.332,66.1,22.337
     C50.114,6.156,36,15.492,36,15.492
     a56,56,0,1,0,56,0Z"
/>
04
Part Four
spiral Keyframe

Step 4 — The spiral Keyframe

The spiral keyframe animates only stroke-dashoffset. Three keyframe stops are all that is needed. The early 25% stop at offset 295 gives the arc a fast initial burst — it covers roughly a quarter of the path in the first quarter of time — before slowing slightly into the back half. This mirrors the feel of something being launched and then gradually decelerating from air resistance.

CSS
@keyframes spiral {
  from { stroke-dashoffset: 10;   }
  25%  { stroke-dashoffset: 295;  }
  to   { stroke-dashoffset: 1165; }
}

The animation runs on .coil-strand at 3s duration with cubic-bezier(0.42, 0.17, 0.75, 0.83) timing and infinite iteration. The easing curve was chosen to feel natural without being too snappy — the 0.17 y1 value creates a gentle ease-in, and the 0.83 y2 creates an ease-out tail.

05
Part Five
jolt Keyframe

Step 5 — The jolt Keyframe

The jolt keyframe animates transform: translate() on the whole .coil element. It runs at 3s linear infinite — the same duration as spiral so both animations stay phase-locked. Most keyframe stops are translate(0, 0), producing stillness. Seven stops apply small non-uniform offsets that simulate a weighted object being tugged by the force of the spinning strand inside it.

Why non-uniform offsets?

Symmetric or evenly-spaced translate values produce a mechanical rock — the eye immediately reads it as a loop. Irregular offsets at irregular timing stops (41%, 43%, 52%, 60%...) feel like genuine physical reaction because no two jolts are the same size or direction. The largest offset (-14.2%, 1.3%) is noticeably bigger than the others, creating a single dominant lurch that anchors the motion rhythm.

CSS
@keyframes jolt {
  from, 41%, 45%, 50%, 54%, 58%, 62%, 66%, 70%, 73%, 77%, 80%, 84%, 87%, 91%, to {
    transform: translate(0, 0);
  }
  43%  { transform: translate(2.1%,    5.4%);  }
  52%  { transform: translate(-14.2%,  1.3%);  }
  60%  { transform: translate(4.8%,   -3.1%);  }
  68%  { transform: translate(-1.4%,  12.6%);  }
  75%  { transform: translate(-3.3%,  -5.9%);  }
  82%  { transform: translate(7.7%,    2.2%);  }
  89%  { transform: translate(-5.1%,   0.8%);  }
}
06
Part Six
BEM-Style Class Naming

Step 6 — Class Naming Conventions

The Off-Track Loader component uses a single-dash BEM variant rather than the double-underscore form. The root element is .coil. Child elements are .coil-track (the static ring) and .coil-strand (the animated path). Single-dash separators are easier to type, read cleanly in DevTools, and remain unambiguous when there is only one level of nesting — which is always the case for a self-contained loader component.

ClassElementRole
.coil<svg>Root element — carries jolt animation, sets width/height in em units
.coil-track<circle>Static guide ring — low-opacity stroke, adapts to dark mode via CSS variable
.coil-strand<path>Animated coil — carries spiral animation, stroke references coil-gradient
07
Part Seven
CSS Custom Properties

Step 7 — CSS Custom Properties

Two custom properties are declared on :root. --surface controls the page background colour. --ink controls the track ring stroke colour. Both are swapped in a prefers-color-scheme: dark block. Naming them semantically makes the intent self-documenting: surface is where content sits, ink is what marks it. Any developer reading the stylesheet understands the role of each variable without needing to trace it back to a colour value.

CSS
:root {
  --surface: hsl(250, 15%, 96%);
  --ink:     hsl(250, 15%, 12%);
}

@media (prefers-color-scheme: dark) {
  :root {
    --surface: hsl(250, 15%, 10%);
    --ink:     hsl(250, 15%, 92%);
  }
}

body {
  background-color: var(--surface);
  color: var(--ink);
}

.coil-track {
  stroke: hsla(250, 15%, 12%, 0.08);
  /* In dark mode swap handled via the media query above */
}

Accessibility Notes

The <svg> carries role="img" and aria-label="Loading" so screen readers announce it as an image with a meaningful label rather than reading out the raw path d attribute. The loader is purely decorative in most contexts — if it is the only feedback that an async operation is in progress, consider also setting aria-live="polite" on a sibling element that announces completion.

Both jolt and spiral use CSS animation rather than JS requestAnimationFrame, which means they automatically pause when the user has prefers-reduced-motion: reduce set — no extra code required. Add @media (prefers-reduced-motion: reduce) { .coil, .coil-strand { animation: none; } } if you want to be explicit rather than relying on browser-level pause behaviour.

Tuning Reference

PropertyDefaultEffect
stroke-dasharray (visible arc)44Increase for a longer visible arc, decrease for a shorter needle-like dot
stroke-dasharray (gap)1111Should stay >= total path length to ensure only one arc is visible at a time
spiral duration3sDecrease for a faster spin, increase for a slow meditative loop
spiral timing functioncubic-bezier(0.42,0.17,0.75,0.83)Controls ease-in and ease-out feel of the arc travel
stroke-width16Set in the SVG attribute — decrease for a hair-thin strand, increase for chunky
jolt translate magnitudes2–14%The largest value (-14.2%) drives the dominant lurch — scale all values proportionally
jolt timing stopsirregularKeeping stops irregular is what makes the motion feel physical rather than looped
Gradient x2/y21 / 1Change to "1 0" for horizontal gradient, "0 1" for vertical
--surface hue250Adjust to match surrounding UI — the track ring alpha will adapt automatically
Track ring alpha0.08Increase for a more visible guide rail, set to 0 to hide it entirely

Full Source Code

Save the following as off-track-loader.html and open in any modern browser. Zero dependencies, zero build step.

HTML — off-track-loader.html (complete)
// Full source available in the Aduok GitHub repository
// https://github.com/aduok/aduok-code-snippets/blob/main/blog/off-track-loader.html

One path, two keyframes, three gradient stops — and a loader that feels like it has weight.

Want to see more CSS-only effects?Watch the full breakdown on YouTube