Juggle

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

const css = `
  /* A three-ball cascade. Across: straight-line speed. Up and down: exact quadratic easing, so
     each throw is a true parabola. Thrown from the inner hand position, caught at the outer
     one, carried in and thrown again from the other side. */
  @keyframes loader-juggle-x {
    0% { transform: translateX(38px); }
    40% { transform: translateX(86px); }
    50% { transform: translateX(62px); }
    90% { transform: translateX(14px); }
    100% { transform: translateX(38px); }
  }
  @keyframes loader-juggle-y {
    0% { transform: translateY(64px); animation-timing-function: cubic-bezier(0.33, 0.67, 0.67, 1); }
    20% { transform: translateY(10px); animation-timing-function: cubic-bezier(0.33, 0, 0.67, 0.33); }
    40% { transform: translateY(62px); animation-timing-function: ease-in-out; }
    45% { transform: translateY(68px); }
    50% { transform: translateY(64px); animation-timing-function: cubic-bezier(0.33, 0.67, 0.67, 1); }
    70% { transform: translateY(10px); animation-timing-function: cubic-bezier(0.33, 0, 0.67, 0.33); }
    90% { transform: translateY(62px); animation-timing-function: ease-in-out; }
    95% { transform: translateY(68px); }
    100% { transform: translateY(64px); }
  }
  /* Each hand throws from inside, swings out to meet the next ball and scoops it back in. */
  @keyframes loader-juggle-hand {
    0% { transform: translate(var(--in), -3px); }
    15% { transform: translate(calc(var(--in) * 0.8 + var(--out) * 0.2), 0); }
    70% { transform: translate(var(--out), 0); }
    85% { transform: translate(calc(var(--in) * 0.5 + var(--out) * 0.5), 4px); }
    100% { transform: translate(var(--in), -3px); }
  }
  .loader-juggle { position: relative; width: 120px; height: 115px; }
  .loader-juggle svg { width: 100%; height: 100%; overflow: visible; }
  .loader-juggle__x { animation: loader-juggle-x 2.1s linear infinite var(--d); }
  .loader-juggle__y { animation: loader-juggle-y 2.1s linear infinite var(--d); }
  .loader-juggle__ball { stroke: light-dark(#fbfcfd, #121417); stroke-width: 1; }
  .loader-juggle__hand { animation: loader-juggle-hand 0.7s ease-in-out infinite var(--d); }
  .loader-juggle__palm { fill: light-dark(#6d7179, #7d8087); }
  .loader-juggle__arm { fill: none; stroke: light-dark(#cbced3, #393c41); stroke-width: 7; stroke-linecap: round; stroke-linejoin: round; }
  @media (prefers-reduced-motion: reduce) {
    .loader-juggle__x, .loader-juggle__y, .loader-juggle__hand { animation: none; }
    .loader-juggle__x { transform: translateX(var(--rx)); }
    .loader-juggle__y { transform: translateY(var(--ry)); }
    .loader-juggle__hand { transform: translate(var(--in), 0); }
  }
`;

const BALLS = [
  { d: 0, fill: 'light-dark(#373a40, #f1f2f3)', rx: '38px', ry: '64px' },
  { d: -0.7, fill: 'light-dark(#50535b, #c6c8cd)', rx: '50px', ry: '10px' },
  { d: -1.4, fill: 'light-dark(#6d7179, #7d8087)', rx: '74px', ry: '64px' },
];
// The right hand runs half a beat behind the left.
const HANDS = [
  { in: '38px', out: '14px', d: 0, arm: 'M-2 5L-12 20L-22 24' },
  { in: '62px', out: '86px', d: -0.35, arm: 'M2 5L12 20L22 24' },
];

export function JuggleLoader() {
  return (
    <>
      <style>
        {css}
      </style>
      <div className="loader-juggle" role="status" aria-label="Loading">
        <svg viewBox="0 0 100 96" aria-hidden="true">
          {HANDS.map((h) => (
            <g key={h.in} transform="translate(0 70)">
              <g className="loader-juggle__hand" style={{ '--in': h.in, '--out': h.out, '--d': `${h.d}s` } as CSSProperties}>
                <path className="loader-juggle__arm" d={h.arm} />
                <path className="loader-juggle__palm" d="M-8 -0.5Q-9 6 0 6.5Q9 6 8 -0.5Q4 1.5 0 1.5Q-4 1.5 -8 -0.5Z" />
              </g>
            </g>
          ))}
          {BALLS.map((b) => (
            <g key={b.d} className="loader-juggle__x" style={{ '--d': `${b.d}s`, '--rx': b.rx } as CSSProperties}>
              <g className="loader-juggle__y" style={{ '--d': `${b.d}s`, '--ry': b.ry } as CSSProperties}>
                <circle className="loader-juggle__ball" r="5.5" style={{ fill: b.fill }} />
              </g>
            </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-juggle
Source on GitHub

About this component

Two hands juggling three balls in a cascade, each ball thrown across in a real arc, caught and carried round to be thrown again.

Dependencies
None
Difficulty
Advanced
Triggers
continuous, loop, delay stagger
Techniques
keyframes, css variables, svg
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 balls hold still mid-throw.

More loaders

See all
Spirit level
Locomotive
Pancake
Popcorn