Back to the library

Handoff baton pass

Variant: Standard

A three-station progress rail where a single filled token travels from the completed stage to the next one, and the receiving station only lights up after the token lands. Reach for it when a task moves between owners, queues, or services and the user needs to see who holds it now rather than read a status string. The token uses ease-in-out because it is moving across the screen rather than entering it, and the receiving ring's confirmation is delayed until arrival so the handoff reads as sequential, not simultaneous.

handoffprogressstateloopstatus

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>Handoff baton pass — Standard</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: 4.2s;
  --t: calc(var(--cycle) / var(--speed));

  --ease-out: cubic-bezier(0.23, 1, 0.32, 1);
  --ease-in-out: cubic-bezier(0.77, 0, 0.175, 1);

  /* File-specific: rail geometry and per-variant weight. */
  --_ring: 2px;
  --_rail: 2px;
  --_token: 13px;
  --_lift-y: -14%;
  --_dim: 0.34;
}

* { box-sizing: border-box; }

html, body { margin: 0; }

body {
  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(420px, 92vw);
  padding: clamp(18px, 5vw, 26px) clamp(16px, 5vw, 26px);
  background: var(--paper);
  border: 1px solid var(--oat);
  border-radius: calc(14px * var(--round));
}

.head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 10px;
  margin-bottom: clamp(20px, 6vw, 28px);
}

.title {
  font-size: clamp(12.5px, 3.4vw, 14px);
  font-weight: 600;
  letter-spacing: 0.01em;
}

.sub {
  font-size: clamp(11px, 3vw, 12px);
  color: color-mix(in oklab, var(--slate) 52%, var(--ivory));
}

.rail {
  position: relative;
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  align-items: start;
}

/* The connecting line sits behind the stations, inset to the first and last centre. */
.line {
  position: absolute;
  left: 16.666%;
  right: 16.666%;
  top: calc(var(--_token) + 6px);
  height: var(--_rail);
  background: var(--oat);
  border-radius: 999px;
}

.station {
  position: relative;
  display: grid;
  justify-items: center;
  gap: 10px;
}

.dot {
  position: relative;
  width: calc(var(--_token) + 12px);
  height: calc(var(--_token) + 12px);
  border-radius: 50%;
  border: var(--_ring) solid var(--oat);
  background: var(--paper);
  display: grid;
  place-items: center;
}

.core {
  width: var(--_token);
  height: var(--_token);
  border-radius: 50%;
  background: var(--clay);
  opacity: 0;
}

.label {
  font-size: clamp(10.5px, 2.9vw, 11.5px);
  text-align: center;
  color: color-mix(in oklab, var(--slate) 55%, var(--ivory));
  opacity: var(--_dim);
}

/* The travelling baton: one element, two hops, parked at station 1 between laps. */
.baton {
  position: absolute;
  top: 6px;
  left: 16.666%;
  width: var(--_token);
  height: var(--_token);
  margin-left: calc(var(--_token) / -2);
  border-radius: 50%;
  background: var(--clay);
  box-shadow: 0 0 0 calc(var(--_ring) + 2px) color-mix(in oklab, var(--clay) 18%, transparent);
  animation: hop var(--t) var(--ease-in-out) infinite;
}

@keyframes hop {
  0%, 16%   { transform: translate(0, 0) scale(1); }
  22%       { transform: translate(0, var(--_lift-y)) scale(0.97); }
  38%, 54%  { transform: translate(100%, 0) scale(1); }
  60%       { transform: translate(100%, var(--_lift-y)) scale(0.97); }
  76%, 92%  { transform: translate(200%, 0) scale(1); }
  /* Fade-return instead of sliding back, so the loop never reads as a reversal. */
  96%       { transform: translate(200%, 0) scale(1); opacity: 1; }
  97.5%     { transform: translate(200%, 0) scale(0.94); opacity: 0; }
  98%       { transform: translate(0, 0) scale(0.94); opacity: 0; }
  100%      { transform: translate(0, 0) scale(1); opacity: 1; }
}

/* Each receiving ring confirms only after the baton has landed on it. */
.s1 .dot { animation: held var(--t) var(--ease-out) infinite; }
.s2 .dot { animation: held var(--t) var(--ease-out) calc(0.38 * var(--t)) infinite; }
.s3 .dot { animation: held var(--t) var(--ease-out) calc(0.76 * var(--t)) infinite; }

@keyframes held {
  0%       { border-color: var(--clay); transform: scale(1); }
  6%       { border-color: var(--clay); transform: scale(1.06); }
  16%      { border-color: var(--clay); transform: scale(1); }
  34%      { border-color: var(--oat);  transform: scale(1); }
  100%     { border-color: var(--oat);  transform: scale(1); }
}

.s1 .label { animation: lit var(--t) var(--ease-out) infinite; }
.s2 .label { animation: lit var(--t) var(--ease-out) calc(0.38 * var(--t)) infinite; }
.s3 .label { animation: lit var(--t) var(--ease-out) calc(0.76 * var(--t)) infinite; }

@keyframes lit {
  0%, 20%  { opacity: 1; }
  36%      { opacity: var(--_dim); }
  100%     { opacity: var(--_dim); }
}

@media (max-width: 380px) {
  .card { padding: 16px 12px; }
  .head { margin-bottom: 18px; }
}

@media (prefers-reduced-motion: reduce) {
  /* Movement drops; the ownership handoff survives as a slow opacity relay. */
  .baton { display: none; }
  .s1 .dot, .s2 .dot, .s3 .dot { animation: heldCalm calc(var(--t) * 2) var(--ease-out) infinite; }
  .s2 .dot { animation-delay: calc(0.38 * var(--t) * 2); }
  .s3 .dot { animation-delay: calc(0.76 * var(--t) * 2); }
  .s1 .label, .s2 .label, .s3 .label { animation: lit calc(var(--t) * 2) var(--ease-out) infinite; }
  .s2 .label { animation-delay: calc(0.38 * var(--t) * 2); }
  .s3 .label { animation-delay: calc(0.76 * var(--t) * 2); }
  .core { opacity: 1; animation: coreCalm calc(var(--t) * 2) var(--ease-out) infinite; }
  .s2 .core { animation-delay: calc(0.38 * var(--t) * 2); }
  .s3 .core { animation-delay: calc(0.76 * var(--t) * 2); }
}

@keyframes heldCalm {
  0%, 16%  { border-color: var(--clay); }
  34%      { border-color: var(--oat); }
  100%     { border-color: var(--oat); }
}

@keyframes coreCalm {
  0%, 16%  { opacity: 1; }
  34%      { opacity: 0.12; }
  100%     { opacity: 0.12; }
}
</style>
</head>
<body>
  <div class="card">
    <div class="head">
      <span class="title">Request handoff</span>
      <span class="sub">in transit</span>
    </div>
    <div class="rail">
      <div class="line"></div>
      <div class="station s1"><div class="dot"><div class="core"></div></div><div class="label">Intake</div></div>
      <div class="station s2"><div class="dot"><div class="core"></div></div><div class="label">Review</div></div>
      <div class="station s3"><div class="dot"><div class="core"></div></div><div class="label">Release</div></div>
      <div class="baton"></div>
    </div>
  </div>
</body>
</html>

The variant family

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