Marble Run
React + Tailwind
const BALL = 6.5;
// Ramp surfaces, top to bottom: each falls the other way, and the lift stands at the right.
const RAMPS = [
[200, 42, 52, 60],
[40, 84, 188, 102],
[200, 126, 52, 144],
[40, 168, 204, 176],
];
const LIFT_X = 214;
const LIFT_TOP = 42;
const LIFT_FOOT = 176;
const up = (y: number) => y - BALL - 0.5;
type Seg = { d: string; len: number; dur: number; ease: string; spin: number };
const line = (x1: number, y1: number, x2: number, y2: number) => Math.hypot(x2 - x1, y2 - y1);
const quad = (x0: number, y0: number, cx: number, cy: number, x1: number, y1: number) => {
let len = 0;
let [px, py] = [x0, y0];
for (let i = 1; i <= 24; i++) {
const t = i / 24;
const x = (1 - t) ** 2 * x0 + 2 * (1 - t) * t * cx + t * t * x1;
const y = (1 - t) ** 2 * y0 + 2 * (1 - t) * t * cy + t * t * y1;
len += Math.hypot(x - px, y - py);
[px, py] = [x, y];
}
return len;
};
// The marble's centre: off the lift, down each ramp, a short fall to the next, into the lift, up.
const SEGS: Seg[] = [];
let cur = [LIFT_X, up(LIFT_TOP)];
const go = (x: number, y: number, dur: number, ease: string, roll: boolean) => {
const len = line(cur[0], cur[1], x, y);
SEGS.push({ d: `L${x} ${y.toFixed(1)}`, len, dur, ease, spin: roll ? (Math.sign(x - cur[0]) * len * 180) / (Math.PI * BALL) : 0 });
cur = [x, y];
};
const fall = (cx: number, cy: number, x: number, y: number) => {
const len = quad(cur[0], cur[1], cx, cy, x, y);
SEGS.push({ d: `Q${cx} ${cy.toFixed(1)} ${x} ${y.toFixed(1)}`, len, dur: 0.15, ease: 'cubic-bezier(0.4, 0, 1, 1)', spin: 0 });
cur = [x, y];
};
go(RAMPS[0][0], up(RAMPS[0][1]), 0.16, 'ease-in', true);
RAMPS.forEach(([x1, y1, x2, y2], i) => {
if (i) {
const [, , px, py] = RAMPS[i - 1];
const dir = Math.sign(x1 - px);
fall(px + dir * 4, up(py) + 2, x1, up(y1));
}
go(x2, up(y2), i === 0 ? 0.66 : 0.58, i === 0 ? 'cubic-bezier(0.5, 0, 0.75, 0.6)' : 'cubic-bezier(0.2, 0, 0.6, 0.6)', true);
});
go(LIFT_X, up(LIFT_FOOT), 0.12, 'ease-out', true);
const LIFT_FROM = SEGS.reduce((s, g) => s + g.dur, 0);
go(LIFT_X, up(LIFT_TOP), 0.85, 'cubic-bezier(0.45, 0, 0.4, 1)', false);
const T = +(LIFT_FROM + 0.85 + 0.25).toFixed(2);
const PATH = `M${LIFT_X} ${up(LIFT_TOP).toFixed(1)}${SEGS.map((g) => g.d).join('')}`;
const TOTAL = SEGS.reduce((s, g) => s + g.len, 0);
const pct = (s: number) => `${((s / T) * 100).toFixed(2)}%`;
let tAt = 0;
let lenAt = 0;
let spinAt = 0;
const travel: string[] = ['0% { offset-distance: 0%; animation-timing-function: ' + SEGS[0].ease + '; }'];
const spin: string[] = ['0% { transform: rotate(0deg); animation-timing-function: ' + SEGS[0].ease + '; }'];
SEGS.forEach((g, i) => {
tAt += g.dur;
lenAt += g.len;
spinAt += g.spin;
const ease = SEGS[i + 1] ? `animation-timing-function: ${SEGS[i + 1].ease};` : '';
travel.push(`${pct(tAt)} { offset-distance: ${((lenAt / TOTAL) * 100).toFixed(2)}%; ${ease} }`);
spin.push(`${pct(tAt)} { transform: rotate(${spinAt.toFixed(0)}deg); ${ease} }`);
});
travel.push('100% { offset-distance: 100%; }');
spin.push(`100% { transform: rotate(${spinAt.toFixed(0)}deg); }`);
// The lift waits at the top while the marble rolls off, goes down empty, then brings it up again.
const lift = `0%, ${pct(0.3)} { transform: translateY(0); animation-timing-function: cubic-bezier(0.45, 0, 0.4, 1); } ${pct(1.3)}, ${pct(LIFT_FROM)} { transform: translateY(${LIFT_FOOT - LIFT_TOP}px); animation-timing-function: cubic-bezier(0.45, 0, 0.4, 1); } ${pct(LIFT_FROM + 0.85)}, 100% { transform: translateY(0); }`;
const css = `
.loader-marble-run { --wood: light-dark(#6d7179, #7d8087); --peg: light-dark(#50535b, #a7abb2); --glass: light-dark(#5f626a, #c6c8cd); --shine: light-dark(#fbfcfd, #f1f2f3); position: relative; width: 230px; height: 186px; }
.loader-marble-run svg { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; }
.loader-marble-run__ramp { fill: none; stroke: var(--wood); stroke-width: 6; stroke-linecap: round; }
.loader-marble-run__edge { fill: none; stroke: color-mix(in oklab, var(--wood) 55%, light-dark(#373a40, #f1f2f3)); stroke-width: 1.4; stroke-linecap: round; }
.loader-marble-run__post { fill: none; stroke: color-mix(in oklab, var(--wood) 70%, transparent); stroke-width: 2.5; stroke-linecap: round; }
.loader-marble-run__peg { fill: var(--peg); }
.loader-marble-run__rail { fill: none; stroke: color-mix(in oklab, var(--wood) 70%, transparent); stroke-width: 2; stroke-linecap: round; }
.loader-marble-run__car { position: absolute; left: ${LIFT_X - 10}px; top: ${LIFT_TOP}px; width: 20px; height: 4px; border-radius: 1px 1px 3px 3px; background: var(--peg); box-shadow: inset 0 -1px 0 color-mix(in oklab, light-dark(#fbfcfd, #121417) 30%, transparent); animation: loader-marble-run-lift ${T}s infinite; }
.loader-marble-run__car::before, .loader-marble-run__car::after { content: ''; position: absolute; bottom: 0; width: 2px; height: 9px; border-radius: 1px; background: var(--peg); }
.loader-marble-run__car::before { left: 0; }
.loader-marble-run__car::after { right: 0; }
/* The marble rides one motion path; its swirl turns the way it rolls. */
.loader-marble-run__marble { position: absolute; left: 0; top: 0; width: ${BALL * 2}px; height: ${BALL * 2}px; offset-path: path('${PATH}'); offset-rotate: 0deg; offset-anchor: center; animation: loader-marble-run-travel ${T}s infinite; filter: drop-shadow(0 2px 1.5px light-dark(rgba(0, 0, 0, 0.11), rgba(0, 0, 0, 0.35))); }
.loader-marble-run__glass { position: absolute; inset: 0; border-radius: 50%; overflow: hidden; background: radial-gradient(circle at 34% 30%, var(--shine) 0 1.4px, color-mix(in oklab, var(--glass) 55%, var(--shine)) 2.5px, var(--glass) 60%, color-mix(in oklab, var(--glass) 70%, light-dark(#fbfcfd, #121417))); }
.loader-marble-run__swirl { position: absolute; inset: 1px; border-radius: 50%; border: 2px solid transparent; border-left-color: color-mix(in oklab, light-dark(#373a40, #f1f2f3) 75%, transparent); border-right-color: color-mix(in oklab, light-dark(#fbfcfd, #121417) 45%, transparent); animation: loader-marble-run-spin ${T}s infinite; }
@keyframes loader-marble-run-travel { ${travel.join(' ')} }
@keyframes loader-marble-run-spin { ${spin.join(' ')} }
@keyframes loader-marble-run-lift { ${lift} }
@media (prefers-reduced-motion: reduce) {
.loader-marble-run * { animation: none !important; }
.loader-marble-run__marble { offset-distance: 22%; }
}
`;
export function Preview() {
return (
<>
<style href="kit-loaders-marble-run" precedence="kit">
{css}
</style>
<div className="loader-marble-run" role="status" aria-label="Loading">
<svg viewBox="0 0 230 186" aria-hidden="true">
<path className="loader-marble-run__rail" d={`M${LIFT_X + 12} ${LIFT_TOP - 12}V${LIFT_FOOT + 6}M${LIFT_X - 12} ${LIFT_FOOT + 6}H${LIFT_X + 12}`} />
{RAMPS.map(([x1, y1, x2, y2]) => (
<g key={y1}>
<path className="loader-marble-run__post" d={`M${x1} ${y1}v10M${x2} ${y2}v10`} />
<path className="loader-marble-run__ramp" d={`M${x1} ${y1 + 2}L${x2} ${y2 + 2}`} />
<path className="loader-marble-run__edge" d={`M${x1} ${y1 - 0.6}L${x2} ${y2 - 0.6}`} />
<circle className="loader-marble-run__peg" cx={x1} cy={y1 + 12} r={2.4} />
<circle className="loader-marble-run__peg" cx={x2} cy={y2 + 12} r={2.4} />
</g>
))}
</svg>
<span className="loader-marble-run__car" aria-hidden="true" />
<span className="loader-marble-run__marble" aria-hidden="true">
<span className="loader-marble-run__glass">
<span className="loader-marble-run__swirl" />
</span>
</span>
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet. No JavaScript.
HTML
<div class="loader-marble-run" role="status" aria-label="Loading">
<svg viewBox="0 0 230 186" aria-hidden="true">
<path class="loader-marble-run__rail" d="M226 30V182M202 182H226"></path>
<g>
<path class="loader-marble-run__post" d="M200 42v10M52 60v10"></path>
<path class="loader-marble-run__ramp" d="M200 44L52 62"></path>
<path class="loader-marble-run__edge" d="M200 41.4L52 59.4"></path>
<circle class="loader-marble-run__peg" cx="200" cy="54" r="2.4"></circle>
<circle class="loader-marble-run__peg" cx="52" cy="72" r="2.4"></circle>
</g>
<g>
<path class="loader-marble-run__post" d="M40 84v10M188 102v10"></path>
<path class="loader-marble-run__ramp" d="M40 86L188 104"></path>
<path class="loader-marble-run__edge" d="M40 83.4L188 101.4"></path>
<circle class="loader-marble-run__peg" cx="40" cy="96" r="2.4"></circle>
<circle class="loader-marble-run__peg" cx="188" cy="114" r="2.4"></circle>
</g>
<g>
<path class="loader-marble-run__post" d="M200 126v10M52 144v10"></path>
<path class="loader-marble-run__ramp" d="M200 128L52 146"></path>
<path class="loader-marble-run__edge" d="M200 125.4L52 143.4"></path>
<circle class="loader-marble-run__peg" cx="200" cy="138" r="2.4"></circle>
<circle class="loader-marble-run__peg" cx="52" cy="156" r="2.4"></circle>
</g>
<g>
<path class="loader-marble-run__post" d="M40 168v10M204 176v10"></path>
<path class="loader-marble-run__ramp" d="M40 170L204 178"></path>
<path class="loader-marble-run__edge" d="M40 167.4L204 175.4"></path>
<circle class="loader-marble-run__peg" cx="40" cy="180" r="2.4"></circle>
<circle class="loader-marble-run__peg" cx="204" cy="188" r="2.4"></circle>
</g>
</svg>
<span class="loader-marble-run__car" aria-hidden="true"></span>
<span class="loader-marble-run__marble" aria-hidden="true">
<span class="loader-marble-run__glass">
<span class="loader-marble-run__swirl"></span>
</span>
</span>
</div>CSS
.loader-marble-run { --wood: light-dark(#6d7179, #7d8087); --peg: light-dark(#50535b, #a7abb2); --glass: light-dark(#5f626a, #c6c8cd); --shine: light-dark(#fbfcfd, #f1f2f3); position: relative; width: 230px; height: 186px; }
.loader-marble-run svg { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; }
.loader-marble-run__ramp { fill: none; stroke: var(--wood); stroke-width: 6; stroke-linecap: round; }
.loader-marble-run__edge { fill: none; stroke: color-mix(in oklab, var(--wood) 55%, light-dark(#373a40, #f1f2f3)); stroke-width: 1.4; stroke-linecap: round; }
.loader-marble-run__post { fill: none; stroke: color-mix(in oklab, var(--wood) 70%, transparent); stroke-width: 2.5; stroke-linecap: round; }
.loader-marble-run__peg { fill: var(--peg); }
.loader-marble-run__rail { fill: none; stroke: color-mix(in oklab, var(--wood) 70%, transparent); stroke-width: 2; stroke-linecap: round; }
.loader-marble-run__car { position: absolute; left: 204px; top: 42px; width: 20px; height: 4px; border-radius: 1px 1px 3px 3px; background: var(--peg); box-shadow: inset 0 -1px 0 color-mix(in oklab, light-dark(#fbfcfd, #121417) 30%, transparent); animation: loader-marble-run-lift 4.23s infinite; }
.loader-marble-run__car::before, .loader-marble-run__car::after { content: ''; position: absolute; bottom: 0; width: 2px; height: 9px; border-radius: 1px; background: var(--peg); }
.loader-marble-run__car::before { left: 0; }
.loader-marble-run__car::after { right: 0; }
/* The marble rides one motion path; its swirl turns the way it rolls. */
.loader-marble-run__marble { position: absolute; left: 0; top: 0; width: 13px; height: 13px; offset-path: path('M214 35.0L200 35.0L52 53.0Q48 55.0 40 77.0L188 95.0Q192 97.0 200 119.0L52 137.0Q48 139.0 40 161.0L204 169.0L214 169.0L214 35.0'); offset-rotate: 0deg; offset-anchor: center; animation: loader-marble-run-travel 4.23s infinite; filter: drop-shadow(0 2px 1.5px light-dark(rgba(0, 0, 0, 0.11), rgba(0, 0, 0, 0.35))); }
.loader-marble-run__glass { position: absolute; inset: 0; border-radius: 50%; overflow: hidden; background: radial-gradient(circle at 34% 30%, var(--shine) 0 1.4px, color-mix(in oklab, var(--glass) 55%, var(--shine)) 2.5px, var(--glass) 60%, color-mix(in oklab, var(--glass) 70%, light-dark(#fbfcfd, #121417))); }
.loader-marble-run__swirl { position: absolute; inset: 1px; border-radius: 50%; border: 2px solid transparent; border-left-color: color-mix(in oklab, light-dark(#373a40, #f1f2f3) 75%, transparent); border-right-color: color-mix(in oklab, light-dark(#fbfcfd, #121417) 45%, transparent); animation: loader-marble-run-spin 4.23s infinite; }
@keyframes loader-marble-run-travel { 0% { offset-distance: 0%; animation-timing-function: ease-in; } 3.78% { offset-distance: 1.65%; animation-timing-function: cubic-bezier(0.5, 0, 0.75, 0.6); } 19.39% { offset-distance: 19.17%; animation-timing-function: cubic-bezier(0.4, 0, 1, 1); } 22.93% { offset-distance: 22.36%; animation-timing-function: cubic-bezier(0.2, 0, 0.6, 0.6); } 36.64% { offset-distance: 39.88%; animation-timing-function: cubic-bezier(0.4, 0, 1, 1); } 40.19% { offset-distance: 43.06%; animation-timing-function: cubic-bezier(0.2, 0, 0.6, 0.6); } 53.90% { offset-distance: 60.59%; animation-timing-function: cubic-bezier(0.4, 0, 1, 1); } 57.45% { offset-distance: 63.77%; animation-timing-function: cubic-bezier(0.2, 0, 0.6, 0.6); } 71.16% { offset-distance: 83.07%; animation-timing-function: ease-out; } 74.00% { offset-distance: 84.25%; animation-timing-function: cubic-bezier(0.45, 0, 0.4, 1); } 94.09% { offset-distance: 100.00%; } 100% { offset-distance: 100%; } }
@keyframes loader-marble-run-spin { 0% { transform: rotate(0deg); animation-timing-function: ease-in; } 3.78% { transform: rotate(-123deg); animation-timing-function: cubic-bezier(0.5, 0, 0.75, 0.6); } 19.39% { transform: rotate(-1438deg); animation-timing-function: cubic-bezier(0.4, 0, 1, 1); } 22.93% { transform: rotate(-1438deg); animation-timing-function: cubic-bezier(0.2, 0, 0.6, 0.6); } 36.64% { transform: rotate(-123deg); animation-timing-function: cubic-bezier(0.4, 0, 1, 1); } 40.19% { transform: rotate(-123deg); animation-timing-function: cubic-bezier(0.2, 0, 0.6, 0.6); } 53.90% { transform: rotate(-1438deg); animation-timing-function: cubic-bezier(0.4, 0, 1, 1); } 57.45% { transform: rotate(-1438deg); animation-timing-function: cubic-bezier(0.2, 0, 0.6, 0.6); } 71.16% { transform: rotate(10deg); animation-timing-function: ease-out; } 74.00% { transform: rotate(98deg); animation-timing-function: cubic-bezier(0.45, 0, 0.4, 1); } 94.09% { transform: rotate(98deg); } 100% { transform: rotate(98deg); } }
@keyframes loader-marble-run-lift { 0%, 7.09% { transform: translateY(0); animation-timing-function: cubic-bezier(0.45, 0, 0.4, 1); } 30.73%, 74.00% { transform: translateY(134px); animation-timing-function: cubic-bezier(0.45, 0, 0.4, 1); } 94.09%, 100% { transform: translateY(0); } }
@media (prefers-reduced-motion: reduce) {
.loader-marble-run * { animation: none !important; }
.loader-marble-run__marble { offset-distance: 22%; }
}Goal: Create a "Marble Run" loader.
Visual behavior: A marble zigzags down four ramps, spinning the way it rolls, and a lift carries it back up to the top.
Technique:
- Pure CSS: every motion is a keyframe animation, so the plain HTML version needs no script.
- The marble rides one offset-path through every ramp and drop. Its distance keyframes are generated per segment, easing in on each slope and dropping under gravity between ramps.
- A spin keyframe turns the marble by the distance it rolls, and reverses on ramps that run the other way.
- At the foot a lift car takes it and climbs back to the top, where it rolls off onto the first ramp again.
Accessibility: The root has role="status" and aria-label="Loading"; everything inside it is aria-hidden. Under prefers-reduced-motion the marble rests part way down the ramps.
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 "Marble Run" loader.
Visual behavior: A marble zigzags down four ramps, spinning the way it rolls, and a lift carries it back up to the top.
Technique:
- Pure CSS: every motion is a keyframe animation, so the plain HTML version needs no script.
- The marble rides one offset-path through every ramp and drop. Its distance keyframes are generated per segment, easing in on each slope and dropping under gravity between ramps.
- A spin keyframe turns the marble by the distance it rolls, and reverses on ramps that run the other way.
- At the foot a lift car takes it and climbs back to the top, where it rolls off onto the first ramp again.
Accessibility: The root has role="status" and aria-label="Loading"; everything inside it is aria-hidden. Under prefers-reduced-motion the marble rests part way down the ramps.
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 marble zigzags down four ramps, spinning the way it rolls, and a lift carries it back up to the top.
- Dependencies
- None
- Difficulty
- Advanced
- 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 it is aria-hidden. Under prefers-reduced-motion the marble rests part way down the ramps.