Locomotive
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>
</>
);
}Markup where the component goes, CSS in your stylesheet. No JavaScript.
HTML
<div class="loader-locomotive" role="status" aria-label="Loading">
<svg viewBox="0 0 132 84" aria-hidden="true">
<circle class="loader-locomotive__puff" cx="101" cy="16" r="5" style="--d:0s"></circle>
<circle class="loader-locomotive__puff" cx="101" cy="16" r="5" style="--d:-0.55s"></circle>
<circle class="loader-locomotive__puff" cx="101" cy="16" r="5" style="--d:-1.1s"></circle>
<g class="loader-locomotive__engine">
<path class="loader-locomotive__dark" d="M97 32V20H95V16H107V20H105V32Z"></path>
<rect class="loader-locomotive__boiler" x="26" y="33" width="72" height="21" rx="9"></rect>
<path class="loader-locomotive__band" d="M44 33.5V53.5M66 33.5V53.5"></path>
<path class="loader-locomotive__dark" d="M58 33A6 6 0 0 1 70 33Z"></path>
<rect class="loader-locomotive__dark" x="92" y="33" width="15" height="21" rx="4"></rect>
<circle class="loader-locomotive__lamp" cx="108" cy="38" r="2.2"></circle>
<rect class="loader-locomotive__dark" x="6" y="22" width="26" height="33" rx="2"></rect>
<rect class="loader-locomotive__deck" x="4" y="19" width="30" height="4" rx="2"></rect>
<rect class="loader-locomotive__window" x="11" y="27" width="13" height="11" rx="1.5"></rect>
<path class="loader-locomotive__dark" d="M107 54L119 72H107Z"></path>
</g>
<g transform="translate(108 69)">
<g class="loader-locomotive__wheel loader-locomotive__wheel--small">
<circle class="loader-locomotive__tyre" r="6"></circle>
<path class="loader-locomotive__spokes" d="M0 -8V8M-8 0H8M-5.66 -5.66L5.66 5.66M-5.66 5.66L5.66 -5.66" transform="scale(0.5)"></path>
<circle class="loader-locomotive__hub" r="1.6"></circle>
</g>
</g>
<g transform="translate(30 65)">
<g class="loader-locomotive__wheel">
<circle class="loader-locomotive__tyre" r="10"></circle>
<path class="loader-locomotive__spokes" d="M0 -8V8M-8 0H8M-5.66 -5.66L5.66 5.66M-5.66 5.66L5.66 -5.66" transform="scale(1)"></path>
<path class="loader-locomotive__weight" d="M-6.93 4A8 8 0 0 1 -6.93 -4Z"></path>
<circle class="loader-locomotive__hub" r="2.4"></circle>
</g>
</g>
<g transform="translate(54 65)">
<g class="loader-locomotive__wheel">
<circle class="loader-locomotive__tyre" r="10"></circle>
<path class="loader-locomotive__spokes" d="M0 -8V8M-8 0H8M-5.66 -5.66L5.66 5.66M-5.66 5.66L5.66 -5.66" transform="scale(1)"></path>
<path class="loader-locomotive__weight" d="M-6.93 4A8 8 0 0 1 -6.93 -4Z"></path>
<circle class="loader-locomotive__hub" r="2.4"></circle>
</g>
</g>
<g transform="translate(78 65)">
<g class="loader-locomotive__wheel">
<circle class="loader-locomotive__tyre" r="10"></circle>
<path class="loader-locomotive__spokes" d="M0 -8V8M-8 0H8M-5.66 -5.66L5.66 5.66M-5.66 5.66L5.66 -5.66" transform="scale(1)"></path>
<path class="loader-locomotive__weight" d="M-6.93 4A8 8 0 0 1 -6.93 -4Z"></path>
<circle class="loader-locomotive__hub" r="2.4"></circle>
</g>
</g>
<g class="loader-locomotive__rod">
<rect class="loader-locomotive__bar" x="34" y="63.5" width="52" height="3" rx="1.5"></rect>
<circle class="loader-locomotive__boss" cx="36" cy="65" r="2"></circle>
<circle class="loader-locomotive__boss" cx="60" cy="65" r="2"></circle>
<circle class="loader-locomotive__boss" cx="84" cy="65" r="2"></circle>
</g>
<rect class="loader-locomotive__deck" x="4" y="54" width="108" height="3" rx="1.5"></rect>
<line class="loader-locomotive__rail" x1="-4" y1="76" x2="136" y2="76"></line>
<g class="loader-locomotive__sleepers">
<rect class="loader-locomotive__sleeper" x="-4" y="77" width="7" height="3" rx="1"></rect>
<rect class="loader-locomotive__sleeper" x="8" y="77" width="7" height="3" rx="1"></rect>
<rect class="loader-locomotive__sleeper" x="20" y="77" width="7" height="3" rx="1"></rect>
<rect class="loader-locomotive__sleeper" x="32" y="77" width="7" height="3" rx="1"></rect>
<rect class="loader-locomotive__sleeper" x="44" y="77" width="7" height="3" rx="1"></rect>
<rect class="loader-locomotive__sleeper" x="56" y="77" width="7" height="3" rx="1"></rect>
<rect class="loader-locomotive__sleeper" x="68" y="77" width="7" height="3" rx="1"></rect>
<rect class="loader-locomotive__sleeper" x="80" y="77" width="7" height="3" rx="1"></rect>
<rect class="loader-locomotive__sleeper" x="92" y="77" width="7" height="3" rx="1"></rect>
<rect class="loader-locomotive__sleeper" x="104" y="77" width="7" height="3" rx="1"></rect>
<rect class="loader-locomotive__sleeper" x="116" y="77" width="7" height="3" rx="1"></rect>
<rect class="loader-locomotive__sleeper" x="128" y="77" width="7" height="3" rx="1"></rect>
<rect class="loader-locomotive__sleeper" x="140" y="77" width="7" height="3" rx="1"></rect>
</g>
</svg>
</div>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; }
}Goal: Create a "Locomotive" loader.
Visual behavior: 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.
Technique:
- The coupling rod rides the crank pins round without turning: its keyframe translates it round a circle of radius 6, sampled sixteen times, in step with the wheels.
- The sleepers slide back at the rim speed of the wheels, so nothing slips, and the small leading wheel turns faster to match. Steam puffs grow and drift back from the chimney.
Accessibility: The root has role="status" and aria-label="Loading"; everything inside is decorative. Under prefers-reduced-motion the engine stands still.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: neutral only. Black, white and grays, no hues. Use the color values above as written, or my project's closest neutral tokens.
Return a single self-contained component. No dependencies.Goal: Create a "Locomotive" loader.
Visual behavior: 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.
Technique:
- The coupling rod rides the crank pins round without turning: its keyframe translates it round a circle of radius 6, sampled sixteen times, in step with the wheels.
- The sleepers slide back at the rim speed of the wheels, so nothing slips, and the small leading wheel turns faster to match. Steam puffs grow and drift back from the chimney.
Accessibility: The root has role="status" and aria-label="Loading"; everything inside is decorative. Under prefers-reduced-motion the engine stands still.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: use my project's existing color tokens. The color values above are grayscale placeholders. Map each one by its role (surface, text, border, accent, glow) to my theme, and keep their relative lightness so contrast and the effect still read.
Return a single self-contained component. No dependencies.Paste into Claude, ChatGPT or Cursor after filling in {{USER_STACK}}, {{USER_STYLING}} and {{USER_CONSTRAINTS}}. Neutral keeps the greys you see here; Match my project asks the model to map them onto your own colour tokens.
Free to use in any project, commercial included. MIT-0, no attribution required. Licence
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.