Marble Run

No dependencies
Code format
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>
    </>
  );
}

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

Install with shadcn

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

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.

More loaders

See all
Pencil
Quarters
Record
Relay