Back to the library

Waypoint hop loader, overshooting legs

Variant: Spring overshoot

The same waypoint loader with a spring-like curve on travel, so the marker overruns each stop by a hair and pulls back into it. Reach for this only where the surrounding product already has personality — onboarding, a playful empty state — since the recoil is the loudest reading of the same timing. The overshoot is capped well inside the track margin so the marker never crosses the card edge at the final stop, which is why the curve overshoots on the vertical axis of the bezier rather than extending the travel distance.

loaderprogresswaypointspringovershoot

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" />
<meta name="animation-knobs" content="overshoot: ok; speed: 0.7-1.3" />
<title>Waypoint hop loader — spring overshoot</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);
  --ease-accent: cubic-bezier(0.34, 1.42, 0.5, 1); /* recoil into each stop */

  /* 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 is wide enough that the marker's recoil never reaches the card edge. */
  margin: 0 calc(var(--_dot) * 1.1);
}

.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) var(--ease-accent) 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;
}

.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) var(--ease-accent) 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>

The variant family

Same idea, one axis moved. Compare them side by side before you commit.