Worm

No dependencies
Code format
React + Tailwind
const S = 0.3; // dot spacing, em
const H = 0.13; // worm thickness, em
const D = 0.08; // dot size, em
const MOVES = [
  [0, 1],
  [1, 2],
  [2, 3],
  [3, 0],
];
const STEP = 0.55;
const T = STEP * MOVES.length;
const MOTION = 0.74; // share of each step spent moving
const LAG = 0.38; // how long the tail waits behind the head, as a share of the move
const ease = (u: number) => (u < 0.5 ? 4 * u ** 3 : 1 - (-2 * u + 2) ** 3 / 2);
const clamp = (u: number) => Math.min(1, Math.max(0, u));
const pct = (t: number) => `${(t * 100).toFixed(2)}%`;

// The edge facing the way it goes moves first and the other follows, so the
// worm stretches onto the next dot and then gathers itself up there.
const frames: string[] = [];
MOVES.forEach(([a, b], k) => {
  const fwd = b > a;
  for (let j = 0; j <= 12; j++) {
    const u = j / 12;
    const head = ease(clamp(u / (1 - LAG)));
    const tail = ease(clamp((u - LAG) / (1 - LAG)));
    const from = a * S;
    const to = b * S;
    const left = from + (to - from) * (fwd ? tail : head);
    const right = from + H + (to - from) * (fwd ? head : tail);
    frames.push(`${pct((k + u * MOTION) / MOVES.length)} { left: ${left.toFixed(4)}em; width: ${(right - left).toFixed(4)}em; }`);
  }
});
frames.push(`100% { left: 0em; width: ${H}em; }`);

const css = `
  .loader-worm { --ink: light-dark(#373a40, #f1f2f3); --track: light-dark(#cbced3, #393c41); position: relative; display: inline-block; font-size: 120px; width: ${(3 * S + H).toFixed(2)}em; height: ${H}em; }
  .loader-worm__dot { position: absolute; top: ${((H - D) / 2).toFixed(3)}em; width: ${D}em; height: ${D}em; border-radius: 50%; background: var(--track); }
  .loader-worm__worm { position: absolute; top: 0; left: 0; width: ${H}em; height: ${H}em; border-radius: ${H / 2}em; background: var(--ink); animation: loader-worm-crawl ${T.toFixed(2)}s infinite; }
  @keyframes loader-worm-crawl {
    ${frames.join('\n    ')}
  }
  @media (prefers-reduced-motion: reduce) {
    .loader-worm__worm { animation: none; }
  }
`;

export function Preview() {
  return (
    <>
      <style href="kit-loaders-worm" precedence="kit">
        {css}
      </style>
      <div className="loader-worm" role="status" aria-label="Loading">
        {[0, 1, 2, 3].map((i) => (
          <span key={i} className="loader-worm__dot" style={{ left: `${(i * S + (H - D) / 2).toFixed(3)}em` }} aria-hidden="true" />
        ))}
        <span className="loader-worm__worm" aria-hidden="true" />
      </div>
    </>
  );
}

Free to use in any project, commercial included. MIT-0, no attribution required. Licence

Install with shadcn

npx shadcn@latest add @movin/loader-worm
Source on GitHub

About this component

A pill crawls along a row of four dots like a page indicator, stretching onto the next dot before its tail follows, then reaches all the way back.

Dependencies
None
Difficulty
Intermediate
Triggers
continuous, loop, morph
Techniques
keyframes, border radius
Reduced motion
Respected
Theme
Light and dark (follows your page's color-scheme)

The root has role="status" and aria-label="Loading"; everything inside it is aria-hidden. Under prefers-reduced-motion the pill sits on the first dot.

More loaders

See all
Abacus
Barber pole
Ferris wheel
Geneva drive