Cycle
How to build cycle
On an ∞ track, a thick stroke's head and its dot sprint through most of a lap in one burst, then crawl the rest. In this two-gear-looking move, you set 2 speed keys — one whip's worth — and the crawl's speed follows from the promise that one loop closes exactly 1 lap. The stroke itself is a fixed-length window: as it slides along the track, the head and tail appear to move.
- Published
- June 11, 2026
- Topics
- Path · Dash · Easing · SVG
Set up a track, a window and a dot
- A closed ∞-shaped track — this demo runs the idealized one with perfectly round lobes, the original is a slightly distorted ∞ drawn with 4 pen-tool anchors
- A thick stroke (
strokeWidthpx wide on astageSizepx-square canvas) coveringdrawnFractionof the track's length taken as 1 — the undrawngapFractionbecomes the gap - A dot riding the head (radius
dotRadiuspx) — the gap straddles the dot, front and back - The head's progress u (0 = start, 1 = a full lap) — 2 keys, the whip starts from
uAtWhipStarton framewhipStartFrameof the loop and coverswhipSpanof the lap inwhipDurFramesframes
You build 2 things: a function that turns a frame number into the head's progress u, and a way to put u on screen. The function first.
// Only one thing moves: the head's progress u. 0 is the track start; 1 is a full lap.
// Bare lowercase names (whipSpan, periodFrames) are numbers you pick for your own canvas —
// what this article wants you to take home is the shape, not any value.
const whip = cubicBezier(...whipHandles); // same standard as CSS cubic-bezier() — the whip's face is any 4 numbers you like
const linear = (t) => t; // the crawl just moves straight
// The crawl is the leftover — not yours to choose
const crawlSpan = 1 - whipSpan; // share of the lap left for the crawl
const crawlDurFrames = periodFrames - whipDurFrames; // frames left for the crawl
// On-screen dials: a stageSize-square canvas, a stroke strokeWidth wide, a dot of radius dotRadius.
// paintHeadLag is the nudge, set by eye, that keeps the gap straddling the dot
const gapFraction = 1 - drawnFraction; // whatever the drawnFraction window leaves undrawn is the gap
// the keyframes placed on u: the whip eats whipSpan of the lap; the rest is the straight line home
const uKeys = [
{ frame: 0, value: uAtWhipStart, curve: whip }, // whip start = launch spot
{ frame: whipDurFrames, value: uAtWhipStart + whipSpan, curve: linear }, // whip end
{ frame: periodFrames, value: uAtWhipStart + 1 }, // same spot, one lap later — not yours to choose
];
// fill between placed keyframes with curves — every motion in this article has this shape
function keyAt(keys, frame) {
if (frame <= keys[0].frame) return keys[0].value; // at or before the first key — hold the first value
if (frame > keys.at(-1).frame) return keys.at(-1).value; // after the last key — hold the last value
const next = keys.findIndex((key) => frame <= key.frame); // index of the key we are heading toward
const a = keys[next - 1], b = keys[next]; // that key and the one before it bound the segment
const t = (frame - a.frame) / (b.frame - a.frame); // progress inside the segment, 0–1
return a.value + (b.value - a.value) * a.curve(t); // advance the gap along the curve
}
// frame number → head progress. Align the whip's start, whipStartFrame, to 0, then interpolate between the placed keyframes
function uAt(frame) {
const local = ((frame - whipStartFrame) % periodFrames + periodFrames) % periodFrames;
return keyAt(uKeys, local) % 1; // anything past 1 wraps into the next lap
}whip is exactly the same math as CSS cubic-bezier(), and the 4 numbers you place in whipHandles go straight into npm's bezier-easing. It holds back at the start, surges through the middle, then sheds speed to hand off into the crawl.
The heart is the last key's value, uAtWhipStart + 1. The moment you decide that periodFrames frames close 1 lap, that span can only be written as the remaining crawlSpan over the remaining crawlDurFrames frames. There is no dial for the crawl's speed — to change it, grow or shrink the whipSpan the whip eats. Change the leftover and the crawl changes on its own.
Two conversions put it on screen. For the line, give the path a pathLength={1} so the track's length reads as 1, then set stroke-dasharray to drawnFraction gapFraction and stroke-dashoffset to drawnFraction + paintHeadLag - u. The window slides, pulled by u, and the gap shifts by paintHeadLag to straddle the dot.
The dot's coordinates come from getPointAtLength(u × total length). The browser already turns distance along a path into a point — you only write up to u.
Drive it with requestAnimationFrame: multiply the elapsed seconds by your frames-per-second to get frame, then take the remainder by periodFrames. The loop's seam sits near the end of the crawl, and the speed across the seam is the crawl's own speed, so nothing pops at the boundary.
The track doesn't have to be an ∞. u only says how far along the track you are, so any closed path — a circle, a heart — works with the same uKeys. Shrink the window's drawnFraction and the look turns into a half-drawn line racing the track.