Locomotive

No dependencies
Code format
React + Tailwind
import type { CSSProperties } from 'react';

const css = `
  @keyframes loader-locomotive-turn { to { transform: rotate(360deg); } }
  /* The coupling rod rides the crank pins round without turning: a circle of radius 6. */
  @keyframes loader-locomotive-rod {
    0% { transform: translate(0px, 0px); }
    6.25% { transform: translate(-0.46px, 2.3px); }
    12.5% { transform: translate(-1.76px, 4.24px); }
    18.75% { transform: translate(-3.7px, 5.54px); }
    25% { transform: translate(-6px, 6px); }
    31.25% { transform: translate(-8.3px, 5.54px); }
    37.5% { transform: translate(-10.24px, 4.24px); }
    43.75% { transform: translate(-11.54px, 2.3px); }
    50% { transform: translate(-12px, 0px); }
    56.25% { transform: translate(-11.54px, -2.3px); }
    62.5% { transform: translate(-10.24px, -4.24px); }
    68.75% { transform: translate(-8.3px, -5.54px); }
    75% { transform: translate(-6px, -6px); }
    81.25% { transform: translate(-3.7px, -5.54px); }
    87.5% { transform: translate(-1.76px, -4.24px); }
    93.75% { transform: translate(-0.46px, -2.3px); }
    100% { transform: translate(0px, 0px); }
  }
  /* Sleepers 12px apart passing at the rim speed of a r10 wheel: 62.8px a turn. */
  @keyframes loader-locomotive-track { to { transform: translateX(-12px); } }
  @keyframes loader-locomotive-puff {
    0% { transform: translate(0, 0) scale(0.3); opacity: 0; }
    10% { opacity: 0.9; }
    100% { transform: translate(-30px, -14px) scale(1.5); opacity: 0; }
  }
  @keyframes loader-locomotive-chuff { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-0.8px); } }
  .loader-locomotive { position: relative; width: 158px; height: 100px; }
  .loader-locomotive svg { width: 100%; height: 100%; overflow: visible; }
  .loader-locomotive__engine { animation: loader-locomotive-chuff 0.55s ease-in-out infinite; }
  .loader-locomotive__wheel { animation: loader-locomotive-turn 1.1s linear infinite; }
  .loader-locomotive__wheel--small { animation-duration: 0.66s; }
  .loader-locomotive__tyre { fill: light-dark(#dbdde1, #2e3035); stroke: light-dark(#5f626a, #a7abb2); stroke-width: 1.6; }
  .loader-locomotive__spokes { stroke: light-dark(#6d7179, #7d8087); stroke-width: 1; }
  .loader-locomotive__weight { fill: light-dark(#6d7179, #7d8087); }
  .loader-locomotive__hub { fill: light-dark(#50535b, #c6c8cd); }
  .loader-locomotive__rod { animation: loader-locomotive-rod 1.1s linear infinite; }
  .loader-locomotive__bar { fill: light-dark(#50535b, #c6c8cd); }
  .loader-locomotive__boss { fill: light-dark(#42454c, #e5e6e9); stroke: light-dark(#cbced3, #393c41); stroke-width: 0.8; }
  .loader-locomotive__boiler { fill: light-dark(#cbced3, #393c41); }
  .loader-locomotive__dark { fill: light-dark(#dbdde1, #2e3035); }
  .loader-locomotive__band { stroke: light-dark(#6d7179, #7d8087); stroke-width: 1.2; }
  .loader-locomotive__window { fill: light-dark(#f3f4f7, #1a1c1f); }
  .loader-locomotive__deck { fill: light-dark(#6d7179, #7d8087); }
  .loader-locomotive__lamp { fill: light-dark(#373a40, #f1f2f3); }
  .loader-locomotive__puff { fill: light-dark(#6d7179, #7d8087); opacity: 0; transform-box: fill-box; transform-origin: center; animation: loader-locomotive-puff 1.65s ease-out infinite var(--d); }
  .loader-locomotive__rail { stroke: light-dark(#6d7179, #7d8087); stroke-width: 2; }
  .loader-locomotive__sleepers { animation: loader-locomotive-track 0.21s linear infinite; }
  .loader-locomotive__sleeper { fill: light-dark(#cbced3, #393c41); }
  @media (prefers-reduced-motion: reduce) {
    .loader-locomotive__engine, .loader-locomotive__wheel, .loader-locomotive__rod, .loader-locomotive__sleepers, .loader-locomotive__puff { animation: none; }
  }
`;

const DRIVERS = [30, 54, 78];
const SPOKES = 'M0 -8V8M-8 0H8M-5.66 -5.66L5.66 5.66M-5.66 5.66L5.66 -5.66';

function Wheel({ x, y, r, small }: { x: number; y: number; r: number; small?: boolean }) {
  return (
    <g transform={`translate(${x} ${y})`}>
      <g className={`loader-locomotive__wheel${small ? ' loader-locomotive__wheel--small' : ''}`}>
        <circle className="loader-locomotive__tyre" r={r} />
        <path className="loader-locomotive__spokes" d={SPOKES} transform={`scale(${(r - 2) / 8})`} />
        {/* The counterweight sits opposite the crank pin. */}
        {!small && <path className="loader-locomotive__weight" d="M-6.93 4A8 8 0 0 1 -6.93 -4Z" />}
        <circle className="loader-locomotive__hub" r={small ? 1.6 : 2.4} />
      </g>
    </g>
  );
}

export function LocomotiveLoader() {
  return (
    <>
      <style>
        {css}
      </style>
      <div className="loader-locomotive" role="status" aria-label="Loading">
        <svg viewBox="0 0 132 84" aria-hidden="true">
          {[0, 0.55, 1.1].map((d) => (
            <circle key={d} className="loader-locomotive__puff" cx="101" cy="16" r="5" style={{ '--d': `${-d}s` } as CSSProperties} />
          ))}
          <g className="loader-locomotive__engine">
            <path className="loader-locomotive__dark" d="M97 32V20H95V16H107V20H105V32Z" />
            <rect className="loader-locomotive__boiler" x="26" y="33" width="72" height="21" rx="9" />
            <path className="loader-locomotive__band" d="M44 33.5V53.5M66 33.5V53.5" />
            <path className="loader-locomotive__dark" d="M58 33A6 6 0 0 1 70 33Z" />
            <rect className="loader-locomotive__dark" x="92" y="33" width="15" height="21" rx="4" />
            <circle className="loader-locomotive__lamp" cx="108" cy="38" r="2.2" />
            <rect className="loader-locomotive__dark" x="6" y="22" width="26" height="33" rx="2" />
            <rect className="loader-locomotive__deck" x="4" y="19" width="30" height="4" rx="2" />
            <rect className="loader-locomotive__window" x="11" y="27" width="13" height="11" rx="1.5" />
            <path className="loader-locomotive__dark" d="M107 54L119 72H107Z" />
          </g>
          <Wheel x={108} y={69} r={6} small />
          {DRIVERS.map((x) => (
            <Wheel key={x} x={x} y={65} r={10} />
          ))}
          <g className="loader-locomotive__rod">
            <rect className="loader-locomotive__bar" x="34" y="63.5" width="52" height="3" rx="1.5" />
            {DRIVERS.map((x) => (
              <circle key={x} className="loader-locomotive__boss" cx={x + 6} cy="65" r="2" />
            ))}
          </g>
          <rect className="loader-locomotive__deck" x="4" y="54" width="108" height="3" rx="1.5" />
          <line className="loader-locomotive__rail" x1="-4" y1="76" x2="136" y2="76" />
          <g className="loader-locomotive__sleepers">
            {Array.from({ length: 13 }, (_, i) => (
              <rect key={i} className="loader-locomotive__sleeper" x={-4 + i * 12} y="77" width="7" height="3" rx="1" />
            ))}
          </g>
        </svg>
      </div>
    </>
  );
}

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

Install with shadcn

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

About this component

A steam engine running on the spot: drive wheels turning, the coupling rod circling with them, steam puffing back from the chimney as the sleepers rush past.

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

The root has role="status" and aria-label="Loading"; everything inside is decorative. Under prefers-reduced-motion the engine stands still.

More loaders

See all
Pancake
Popcorn
Receipt
Snail