Back to the library

Waypoint hop loader, linear legs

Variant: Linear legs

The same waypoint loader with every hop moving at a constant rate, so the marker crosses each leg like a vehicle at cruising speed rather than lunging and settling. Reach for this when the loader sits next to other constant-rate indicators — a marquee, a scanning bar — and the mixed curves would read as inconsistent. Because the travel is flat, the holds at each stop carry all the rhythm, which makes the pauses feel longer than they measure.

loaderprogresswaypointlinearindeterminate

The source

A complete HTML document. Paste it into an empty .html file and it plays — no stylesheet, script, font, or image to fetch.

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Waypoint hop loader — linear legs</title>
<style>
:root {
  /* Neutrals — fixed, never remixed. */
  --ivory: #faf9f5;
  --paper: #ffffff;
  --oat: #e3dacc;
  --slate: #141413;

  /* Knobs — the remix API. A knob exists iff declared here and used below. */
  --hue: 38.8;
  --lift: 0;
  --chroma: 1;
  --round: 1;
  --speed: 1;

  /* Accents — derived from the knobs with the family's offsets baked in,
     so one hue roll moves them together. Defaults reproduce the original hex
     (#d97757 / #b85c3e / #788c5d / #b04a3f). */
  --clay: oklch(calc(0.6724 + var(--lift)) calc(0.1308 * var(--chroma)) var(--hue));
  --clay-d: oklch(calc(0.5797 + var(--lift)) calc(0.127 * var(--chroma)) var(--hue));
  --olive: oklch(calc(0.6118 + var(--lift)) calc(0.0713 * var(--chroma)) calc(var(--hue) + 88.3));
  --rust: oklch(calc(0.5408 + var(--lift)) calc(0.1357 * var(--chroma)) calc(var(--hue) - 10.3));

  /* Clock — every duration in the document runs off --t. */
  --cycle: 4s; /* the authored master loop */
  --t: calc(var(--cycle) / var(--speed));

  --ease-out: cubic-bezier(0.23, 1, 0.32, 1);

  /* File-specific: the four legs of the route, as percentages of the track. */
  --_p0: 0%;
  --_p1: 33.333%;
  --_p2: 66.667%;
  --_p3: 100%;
  --_dot: clamp(9px, 2.8vw, 11px);
}

* { box-sizing: border-box; }

body {
  margin: 0;
  min-height: 100vh;
  display: grid;
  place-items: center;
  background: var(--ivory);
  color: var(--slate);
  font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
}

.card {
  width: min(400px, 90vw);
  padding: clamp(18px, 5vw, 26px) clamp(18px, 5vw, 28px);
  background: var(--paper);
  border: 1px solid var(--oat);
  border-radius: calc(14px * var(--round));
  display: grid;
  gap: clamp(14px, 4vw, 20px);
}

.label {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 12px;
  font-size: clamp(12px, 3.2vw, 13px);
  letter-spacing: 0.01em;
}

.label .lead { font-weight: 600; }

.label .sub {
  color: var(--clay-d);
  font-variant-numeric: tabular-nums;
  font-size: clamp(11px, 3vw, 12px);
  animation: breathe var(--t) linear infinite;
}

@keyframes breathe {
  0%, 100% { opacity: 0.55; }
  50%      { opacity: 1; }
}

.route {
  position: relative;
  height: calc(var(--_dot) * 2.4);
  margin: 0 calc(var(--_dot) / 2);
}

.rail {
  position: absolute;
  left: 0;
  right: 0;
  top: 50%;
  height: 2px;
  margin-top: -1px;
  background-image: linear-gradient(90deg, var(--oat) 0 55%, transparent 55% 100%);
  background-size: 9px 2px;
  border-radius: 999px;
}

.rail-done {
  position: absolute;
  left: 0;
  right: 0;
  top: 50%;
  height: 2px;
  margin-top: -1px;
  background: var(--clay);
  border-radius: 999px;
  clip-path: inset(0 100% 0 0);
  animation: trail var(--t) linear infinite;
}

@keyframes trail {
  0%                { clip-path: inset(0 100% 0 0); }
  16%               { clip-path: inset(0 calc(100% - var(--_p1)) 0 0); }
  28%               { clip-path: inset(0 calc(100% - var(--_p1)) 0 0); }
  44%               { clip-path: inset(0 calc(100% - var(--_p2)) 0 0); }
  56%               { clip-path: inset(0 calc(100% - var(--_p2)) 0 0); }
  72%               { clip-path: inset(0 0 0 0); }
  88%               { clip-path: inset(0 0 0 0); }
  100%              { clip-path: inset(0 0 0 100%); }
}

.stops {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: space-between;
}

.stop {
  width: var(--_dot);
  height: var(--_dot);
  border-radius: 50%;
  background: var(--paper);
  border: 2px solid var(--oat);
  position: relative;
}

/* The arrival flash stays ease-out in every sibling; only travel changes. */
.stop::after {
  content: '';
  position: absolute;
  inset: -2px;
  border-radius: 50%;
  background: var(--clay);
  transform: scale(0.35);
  opacity: 0;
  animation: arrive var(--t) var(--ease-out) infinite;
  animation-delay: var(--_d);
}

.stop:nth-child(1) { --_d: calc(0s / var(--speed)); }
.stop:nth-child(2) { --_d: calc(0.64s / var(--speed)); }
.stop:nth-child(3) { --_d: calc(1.76s / var(--speed)); }
.stop:nth-child(4) { --_d: calc(2.88s / var(--speed)); }

@keyframes arrive {
  0%   { transform: scale(0.35); opacity: 0; }
  9%   { transform: scale(1);    opacity: 1; }
  86%  { transform: scale(1);    opacity: 1; }
  96%  { transform: scale(1);    opacity: 0; }
  100% { transform: scale(0.35); opacity: 0; }
}

.marker {
  position: absolute;
  top: 50%;
  left: 0;
  width: calc(var(--_dot) * 1.7);
  height: calc(var(--_dot) * 1.7);
  margin-left: calc(var(--_dot) * -0.85);
  margin-top: calc(var(--_dot) * -0.85);
  animation: hop var(--t) linear infinite;
}

.marker::before {
  content: '';
  position: absolute;
  inset: 0;
  border-radius: 50%;
  border: 2px solid var(--clay-d);
  background: var(--paper);
}

.marker::after {
  content: '';
  position: absolute;
  inset: 28%;
  border-radius: 50%;
  background: var(--rust);
}

@keyframes hop {
  0%   { transform: translateX(var(--_p0)); opacity: 0.15; }
  6%   { transform: translateX(var(--_p0)); opacity: 1; }
  16%  { transform: translateX(var(--_p1)); }
  28%  { transform: translateX(var(--_p1)); }
  44%  { transform: translateX(var(--_p2)); }
  56%  { transform: translateX(var(--_p2)); }
  72%  { transform: translateX(var(--_p3)); }
  88%  { transform: translateX(var(--_p3)); opacity: 1; }
  97%  { transform: translateX(var(--_p3)); opacity: 0; }
  100% { transform: translateX(var(--_p3)); opacity: 0; }
}

@media (max-width: 380px) {
  .card { padding: 16px; gap: 13px; }
}

@media (prefers-reduced-motion: reduce) {
  .rail-done { animation: none; clip-path: inset(0 0 0 0); opacity: 0.35; }
  .marker { animation: none; transform: translateX(var(--_p3)); opacity: 1; }
  .stop::after {
    transform: scale(1);
    animation: arrive-quiet calc(var(--t) * 2) var(--ease-out) infinite;
  }
  .label .sub { animation: breathe calc(var(--t) * 2) linear infinite; }
  @keyframes arrive-quiet {
    0%, 100% { opacity: 0.3; }
    50%      { opacity: 1; }
  }
}
</style>
</head>
<body>
  <div class="card">
    <div class="label">
      <span class="lead">Syncing workspace</span>
      <span class="sub">gathering sources</span>
    </div>
    <div class="route">
      <div class="rail"></div>
      <div class="rail-done"></div>
      <div class="stops">
        <span class="stop"></span>
        <span class="stop"></span>
        <span class="stop"></span>
        <span class="stop"></span>
      </div>
      <div class="marker"></div>
    </div>
  </div>
</body>
</html>