Rally
import { useEffect, useRef } from 'react';
const css = `
.loader-rally {
position: relative;
width: 128px;
height: 80px;
border-radius: 8px;
background: light-dark(#fbfcfd, #121417);
box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035);
overflow: hidden;
}
.loader-rally__net {
position: absolute;
left: 50%;
top: 6px;
bottom: 6px;
width: 2px;
margin-left: -1px;
background: repeating-linear-gradient(light-dark(#cbced3, #393c41) 0 4px, transparent 4px 8px);
}
.loader-rally__paddle,
.loader-rally__ball {
position: absolute;
left: 0;
top: 0;
background: light-dark(#373a40, #f1f2f3);
will-change: transform;
}
.loader-rally__paddle { width: 4px; height: 18px; border-radius: 2px; }
.loader-rally__ball { width: 6px; height: 6px; border-radius: 1px; box-shadow: 0 0 8px light-dark(#373a40, #f1f2f3); }
`;
const W = 128;
const H = 80;
export function RallyLoader() {
const ball = useRef<HTMLSpanElement>(null);
const left = useRef<HTMLSpanElement>(null);
const right = useRef<HTMLSpanElement>(null);
useEffect(() => {
const still = matchMedia('(prefers-reduced-motion: reduce)').matches;
const minX = 12;
const maxX = W - 18;
let x = minX;
let y = 30;
let vx = 95;
let vy = 61;
let pl = 30;
let pr = 30;
let last = performance.now();
let raf = 0;
const tick = (now: number) => {
const dt = Math.min(0.04, (now - last) / 1000);
last = now;
x += vx * dt;
y += vy * dt;
if (y < 4 || y > H - 10) {
vy = -vy;
y = Math.max(4, Math.min(H - 10, y));
}
if (x > maxX || x < minX) {
vx = -vx;
x = Math.max(minX, Math.min(maxX, x));
vy += (Math.random() - 0.5) * 30; // a little english off the paddle
}
// Each paddle chases the ball only while it is coming their way, and a touch late.
const target = y - 6;
if (vx < 0) pl += (target - pl) * 0.12;
else pr += (target - pr) * 0.12;
ball.current!.style.transform = `translate(${x.toFixed(1)}px, ${y.toFixed(1)}px)`;
left.current!.style.transform = `translate(6px, ${pl.toFixed(1)}px)`;
right.current!.style.transform = `translate(${W - 10}px, ${pr.toFixed(1)}px)`;
if (!still) raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
}, []);
return (
<>
<style>
{css}
</style>
<div className="loader-rally" role="status" aria-label="Loading">
<span className="loader-rally__net" aria-hidden="true" />
<span ref={left} className="loader-rally__paddle" aria-hidden="true" />
<span ref={right} className="loader-rally__paddle" aria-hidden="true" />
<span ref={ball} className="loader-rally__ball" aria-hidden="true" />
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initLoaderRally(el) on the mounted element instead.
<div class="loader-rally" role="status" aria-label="Loading">
<span class="loader-rally__net" aria-hidden="true"></span>
<span class="loader-rally__paddle" aria-hidden="true"></span>
<span class="loader-rally__paddle" aria-hidden="true"></span>
<span class="loader-rally__ball" aria-hidden="true"></span>
</div>.loader-rally {
position: relative;
width: 128px;
height: 80px;
border-radius: 8px;
background: light-dark(#fbfcfd, #121417);
box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035);
overflow: hidden;
}
.loader-rally__net {
position: absolute;
left: 50%;
top: 6px;
bottom: 6px;
width: 2px;
margin-left: -1px;
background: repeating-linear-gradient(light-dark(#cbced3, #393c41) 0 4px, transparent 4px 8px);
}
.loader-rally__paddle,
.loader-rally__ball {
position: absolute;
left: 0;
top: 0;
background: light-dark(#373a40, #f1f2f3);
will-change: transform;
}
.loader-rally__paddle { width: 4px; height: 18px; border-radius: 2px; }
.loader-rally__ball { width: 6px; height: 6px; border-radius: 1px; box-shadow: 0 0 8px light-dark(#373a40, #f1f2f3); }function initLoaderRally(root) {
var ball = root.querySelector('.loader-rally__ball');
var paddles = root.querySelectorAll('.loader-rally__paddle');
if (!ball || paddles.length !== 2) return;
var W = 128;
var H = 80;
var minX = 12;
var maxX = W - 18;
var still = matchMedia('(prefers-reduced-motion: reduce)').matches;
var x = minX;
var y = 30;
var vx = 95;
var vy = 61;
var pl = 30;
var pr = 30;
var last = performance.now();
var raf = 0;
function tick(now) {
var dt = Math.min(0.04, (now - last) / 1000);
last = now;
x += vx * dt;
y += vy * dt;
if (y < 4 || y > H - 10) {
vy = -vy;
y = Math.max(4, Math.min(H - 10, y));
}
if (x > maxX || x < minX) {
vx = -vx;
x = Math.max(minX, Math.min(maxX, x));
vy += (Math.random() - 0.5) * 30; // a little spin off the paddle
}
// Each paddle chases the ball only while it is coming their way, and a touch late.
if (vx < 0) pl += (y - 6 - pl) * 0.12;
else pr += (y - 6 - pr) * 0.12;
ball.style.transform = 'translate(' + x.toFixed(1) + 'px, ' + y.toFixed(1) + 'px)';
paddles[0].style.transform = 'translate(6px, ' + pl.toFixed(1) + 'px)';
paddles[1].style.transform = 'translate(' + (W - 10) + 'px, ' + pr.toFixed(1) + 'px)';
if (!still) raf = requestAnimationFrame(tick);
}
raf = requestAnimationFrame(tick);
return function () { cancelAnimationFrame(raf); };
}
document.querySelectorAll('.loader-rally').forEach(initLoaderRally);Goal: Create a "Rally" loader.
Visual behavior: Two paddles keep a ball in play across a dashed net, like the first video game: the ball bounces off the walls, picks up a little spin off each paddle, and each paddle tracks it only while it is coming its way.
Technique:
- A rAF loop moves the ball by its velocity each frame, reflecting vy off the top and bottom and vx at each paddle line.
- Every paddle return adds a small random change to vy, so rallies never repeat exactly.
- Only the paddle the ball is heading toward eases toward it (12% per frame), which makes them look like they are reacting, a touch late.
- Ball and paddles are positioned with transforms only; the net is a dashed repeating gradient.
Accessibility: The root has role="status" and aria-label="Loading"; everything inside is decorative. Under prefers-reduced-motion one still frame is shown.
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 "Rally" loader.
Visual behavior: Two paddles keep a ball in play across a dashed net, like the first video game: the ball bounces off the walls, picks up a little spin off each paddle, and each paddle tracks it only while it is coming its way.
Technique:
- A rAF loop moves the ball by its velocity each frame, reflecting vy off the top and bottom and vx at each paddle line.
- Every paddle return adds a small random change to vy, so rallies never repeat exactly.
- Only the paddle the ball is heading toward eases toward it (12% per frame), which makes them look like they are reacting, a touch late.
- Ball and paddles are positioned with transforms only; the net is a dashed repeating gradient.
Accessibility: The root has role="status" and aria-label="Loading"; everything inside is decorative. Under prefers-reduced-motion one still frame is shown.
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 paddles keep a ball in play across a dashed net, like the first video game: the ball bounces off the walls, picks up a little spin off each paddle, and each paddle tracks it only while it is coming its way.
- Dependencies
- None
- Difficulty
- Intermediate
- Triggers
- continuous, loop
- Techniques
- raf, state machine
- 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 one still frame is shown.