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.
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.
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.
<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>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.
<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>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.
<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"
/>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.
@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.
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.
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.
@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%); }
}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.
| Class | Element | Role |
|---|---|---|
| .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 |
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.
: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
| Property | Default | Effect |
|---|---|---|
| stroke-dasharray (visible arc) | 44 | Increase for a longer visible arc, decrease for a shorter needle-like dot |
| stroke-dasharray (gap) | 1111 | Should stay >= total path length to ensure only one arc is visible at a time |
| spiral duration | 3s | Decrease for a faster spin, increase for a slow meditative loop |
| spiral timing function | cubic-bezier(0.42,0.17,0.75,0.83) | Controls ease-in and ease-out feel of the arc travel |
| stroke-width | 16 | Set in the SVG attribute — decrease for a hair-thin strand, increase for chunky |
| jolt translate magnitudes | 2–14% | The largest value (-14.2%) drives the dominant lurch — scale all values proportionally |
| jolt timing stops | irregular | Keeping stops irregular is what makes the motion feel physical rather than looped |
| Gradient x2/y2 | 1 / 1 | Change to "1 0" for horizontal gradient, "0 1" for vertical |
| --surface hue | 250 | Adjust to match surrounding UI — the track ring alpha will adapt automatically |
| Track ring alpha | 0.08 | Increase 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.
// Full source available in the Aduok GitHub repository
// https://github.com/aduok/aduok-code-snippets/blob/main/blog/off-track-loader.htmlOne path, two keyframes, three gradient stops — and a loader that feels like it has weight.