Juggle
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>
</>
);
}Markup where the component goes, CSS in your stylesheet. No JavaScript.
HTML
<div class="loader-juggle" role="status" aria-label="Loading">
<svg viewBox="0 0 100 96" aria-hidden="true">
<g transform="translate(0 70)">
<g class="loader-juggle__hand" style="--in:38px;--out:14px;--d:0s">
<path class="loader-juggle__arm" d="M-2 5L-12 20L-22 24"></path>
<path class="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"></path>
</g>
</g>
<g transform="translate(0 70)">
<g class="loader-juggle__hand" style="--in:62px;--out:86px;--d:-0.35s">
<path class="loader-juggle__arm" d="M2 5L12 20L22 24"></path>
<path class="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"></path>
</g>
</g>
<g class="loader-juggle__x" style="--d:0s;--rx:38px">
<g class="loader-juggle__y" style="--d:0s;--ry:64px">
<circle class="loader-juggle__ball" r="5.5" style="fill:light-dark(#373a40, #f1f2f3)"></circle>
</g>
</g>
<g class="loader-juggle__x" style="--d:-0.7s;--rx:50px">
<g class="loader-juggle__y" style="--d:-0.7s;--ry:10px">
<circle class="loader-juggle__ball" r="5.5" style="fill:light-dark(#50535b, #c6c8cd)"></circle>
</g>
</g>
<g class="loader-juggle__x" style="--d:-1.4s;--rx:74px">
<g class="loader-juggle__y" style="--d:-1.4s;--ry:64px">
<circle class="loader-juggle__ball" r="5.5" style="fill:light-dark(#6d7179, #7d8087)"></circle>
</g>
</g>
</svg>
</div>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); }
}Goal: Create a "Juggle" loader.
Visual behavior: Two hands juggling three balls in a cascade, each ball thrown across in a real arc, caught and carried round to be thrown again.
Technique:
- Each ball nests a group moving across at a steady speed and a group moving up and down on exact quadratic curves, so every throw is a true parabola.
- The three balls run one keyframe a third of a cycle apart, so each hand throws in turn. The hands run a keyframe a third as long, swinging out to catch and scooping in to throw, the right half a beat behind the left.
Accessibility: The root has role="status" and aria-label="Loading"; everything inside is decorative. Under prefers-reduced-motion the balls hold still mid-throw.
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 "Juggle" loader.
Visual behavior: Two hands juggling three balls in a cascade, each ball thrown across in a real arc, caught and carried round to be thrown again.
Technique:
- Each ball nests a group moving across at a steady speed and a group moving up and down on exact quadratic curves, so every throw is a true parabola.
- The three balls run one keyframe a third of a cycle apart, so each hand throws in turn. The hands run a keyframe a third as long, swinging out to catch and scooping in to throw, the right half a beat behind the left.
Accessibility: The root has role="status" and aria-label="Loading"; everything inside is decorative. Under prefers-reduced-motion the balls hold still mid-throw.
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
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.