Bounce Loader
Original · freebouncing ball loader
byWeb InnoventixReact + Tailwind
loadbounceloaders
Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.
npx shadcn@latest add https://webinnoventix.com/r/load-bounce.jsonload-bounce.tsx
"use client";
import {
useEffect,
useRef,
useState,
type CSSProperties,
type KeyboardEvent,
} from "react";
import { motion, useReducedMotion } from "motion/react";
const COUNTS = [1, 2, 3, 4, 5] as const;
const MESSAGES = [
"Warming up the preview server",
"Syncing changes from your last session",
"Optimizing 2,480 assets",
"Compiling components",
"Almost ready",
] as const;
function PlayIcon() {
return (
<svg viewBox="0 0 16 16" className="h-4 w-4" aria-hidden="true">
<path d="M4 3.4v9.2a.6.6 0 0 0 .92.5l7.2-4.6a.6.6 0 0 0 0-1L4.92 2.9A.6.6 0 0 0 4 3.4Z" fill="currentColor" />
</svg>
);
}
function PauseIcon() {
return (
<svg viewBox="0 0 16 16" className="h-4 w-4" aria-hidden="true">
<rect x="4" y="3.2" width="3" height="9.6" rx="1.2" fill="currentColor" />
<rect x="9" y="3.2" width="3" height="9.6" rx="1.2" fill="currentColor" />
</svg>
);
}
export default function LoadBounce() {
const prefersReduced = useReducedMotion();
const [playing, setPlaying] = useState<boolean>(true);
const [duration, setDuration] = useState<number>(0.9);
const [count, setCount] = useState<number>(3);
const [msgIndex, setMsgIndex] = useState<number>(0);
const optionRefs = useRef<Array<HTMLButtonElement | null>>([]);
useEffect(() => {
if (!playing) return;
const id = window.setInterval(() => {
setMsgIndex((prev) => (prev + 1) % MESSAGES.length);
}, 2600);
return () => window.clearInterval(id);
}, [playing]);
const handleKeyNav = (event: KeyboardEvent<HTMLButtonElement>, index: number) => {
let next = index;
if (event.key === "ArrowRight" || event.key === "ArrowDown") {
next = (index + 1) % COUNTS.length;
} else if (event.key === "ArrowLeft" || event.key === "ArrowUp") {
next = (index - 1 + COUNTS.length) % COUNTS.length;
} else if (event.key === "Home") {
next = 0;
} else if (event.key === "End") {
next = COUNTS.length - 1;
} else {
return;
}
event.preventDefault();
setCount(COUNTS[next]);
optionRefs.current[next]?.focus();
};
const stagger = duration * 0.16;
return (
<section className="relative w-full overflow-hidden bg-slate-50 px-6 py-20 dark:bg-slate-950 sm:py-28">
<style>{`
@keyframes lb-bounce {
0% { transform: translateY(0); animation-timing-function: cubic-bezier(0.33, 0, 0.32, 1); }
50% { transform: translateY(-150%); animation-timing-function: cubic-bezier(0.7, 0, 0.66, 1); }
100% { transform: translateY(0); }
}
@keyframes lb-squash {
0% { transform: scale(1.2, 0.8); }
12% { transform: scale(0.9, 1.1); }
30% { transform: scale(1, 1); }
70% { transform: scale(1, 1); }
88% { transform: scale(0.93, 1.07); }
100% { transform: scale(1.2, 0.8); }
}
@keyframes lb-shadow {
0% { transform: scale(1); opacity: 0.35; }
50% { transform: scale(0.5); opacity: 0.12; }
100% { transform: scale(1); opacity: 0.35; }
}
@keyframes lb-pulse {
0% { opacity: 1; }
50% { opacity: 0.35; }
100% { opacity: 1; }
}
.lb-ball-wrap { animation-name: lb-bounce; animation-iteration-count: infinite; }
.lb-ball { animation-name: lb-squash; animation-iteration-count: infinite; transform-origin: center bottom; }
.lb-shadow { animation-name: lb-shadow; animation-iteration-count: infinite; animation-timing-function: ease-in-out; }
@media (prefers-reduced-motion: reduce) {
.lb-ball-wrap { animation-name: none !important; transform: none !important; }
.lb-shadow { animation-name: none !important; }
.lb-ball { animation-name: lb-pulse !important; animation-timing-function: ease-in-out !important; }
}
`}</style>
<div
aria-hidden="true"
className="pointer-events-none absolute -top-24 left-1/2 h-72 w-72 -translate-x-1/2 rounded-full bg-gradient-to-br from-indigo-300/40 via-violet-300/30 to-transparent blur-3xl dark:from-indigo-600/20 dark:via-violet-600/15"
/>
<div className="relative mx-auto w-full max-w-xl">
<div className="mb-6 text-center">
<span className="inline-flex items-center gap-1.5 rounded-full border border-slate-200 bg-white px-3 py-1 text-xs font-medium text-slate-600 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-300">
<span className="h-1.5 w-1.5 rounded-full bg-indigo-500" />
Loader
</span>
<h2 className="mt-4 text-2xl font-semibold tracking-tight text-slate-900 dark:text-slate-50">
Bouncing ball loader
</h2>
<p className="mt-2 text-sm text-slate-500 dark:text-slate-400">
An indeterminate loading indicator with real squash-and-stretch physics.
</p>
</div>
<motion.div
initial={prefersReduced ? false : { opacity: 0, y: 14 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, ease: [0.22, 1, 0.36, 1] }}
className="rounded-3xl border border-slate-200 bg-white shadow-xl shadow-slate-900/5 dark:border-slate-800 dark:bg-slate-900 dark:shadow-black/20"
>
<div className="flex items-start justify-between gap-4 px-6 pt-6">
<div>
<h3 className="text-sm font-semibold text-slate-900 dark:text-slate-100">
Loading workspace
</h3>
<p
role="status"
aria-live="polite"
className="mt-1 text-sm text-slate-500 dark:text-slate-400"
>
{MESSAGES[msgIndex]}
{playing ? "…" : " (paused)"}
</p>
</div>
<span className="shrink-0 rounded-full bg-slate-100 px-2.5 py-1 font-mono text-xs text-slate-500 dark:bg-slate-800 dark:text-slate-400">
{count} · {duration.toFixed(1)}s
</span>
</div>
<div
role="img"
aria-label="Bouncing ball loading animation"
className="relative flex h-32 items-stretch justify-center gap-3 px-6"
>
<div
aria-hidden="true"
className="pointer-events-none absolute inset-x-8 bottom-4 h-px bg-gradient-to-r from-transparent via-slate-300/80 to-transparent dark:via-slate-700"
/>
{Array.from({ length: count }).map((_, i) => {
const style: CSSProperties = {
animationDuration: `${duration}s`,
animationDelay: `${(-i * stagger).toFixed(3)}s`,
animationPlayState: playing ? "running" : "paused",
};
return (
<div key={i} className="relative h-full w-8">
<span className="absolute bottom-3 left-1/2 -translate-x-1/2">
<span className="lb-shadow block h-1.5 w-6 rounded-[50%] bg-slate-900/30 dark:bg-black/60" style={style} />
</span>
<span className="absolute bottom-4 left-1/2 -translate-x-1/2">
<span className="lb-ball-wrap block" style={style}>
<span
className="lb-ball relative block h-6 w-6 rounded-full bg-gradient-to-b from-indigo-400 to-violet-600 shadow-lg shadow-violet-500/30"
style={style}
>
<span
aria-hidden="true"
className="absolute left-1.5 top-1 h-1.5 w-1.5 rounded-full bg-white/70 blur-[1px]"
/>
</span>
</span>
</span>
</div>
);
})}
</div>
<div className="grid gap-5 border-t border-slate-200 px-6 py-5 dark:border-slate-800 sm:grid-cols-[auto_1fr]">
<button
type="button"
onClick={() => setPlaying((prev) => !prev)}
aria-pressed={playing}
aria-label={playing ? "Pause animation" : "Play animation"}
className="inline-flex h-11 items-center justify-center gap-2 rounded-full bg-indigo-600 px-5 text-sm font-medium text-white transition-colors hover:bg-indigo-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900"
>
{playing ? <PauseIcon /> : <PlayIcon />}
<span>{playing ? "Pause" : "Play"}</span>
</button>
<div className="flex flex-col justify-center gap-1.5">
<div className="flex items-baseline justify-between">
<label
htmlFor="lb-duration"
className="text-xs font-medium text-slate-600 dark:text-slate-300"
>
Bounce duration
</label>
<span className="font-mono text-xs text-slate-500 dark:text-slate-400">
{duration.toFixed(1)}s
</span>
</div>
<input
id="lb-duration"
type="range"
min={0.4}
max={1.8}
step={0.1}
value={duration}
onChange={(event) => setDuration(Number(event.target.value))}
aria-valuetext={`${duration.toFixed(1)} seconds per bounce`}
className="h-2 w-full cursor-pointer appearance-none rounded-full bg-slate-200 accent-indigo-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:bg-slate-700 dark:accent-indigo-400 dark:focus-visible:ring-offset-slate-900"
/>
</div>
<div className="flex items-center justify-between gap-4 sm:col-span-2">
<span
id="lb-count-label"
className="text-xs font-medium text-slate-600 dark:text-slate-300"
>
Number of balls
</span>
<div
role="radiogroup"
aria-labelledby="lb-count-label"
className="inline-flex rounded-full border border-slate-200 bg-slate-100 p-1 dark:border-slate-700 dark:bg-slate-800"
>
{COUNTS.map((c, i) => {
const active = count === c;
return (
<button
key={c}
ref={(el) => {
optionRefs.current[i] = el;
}}
type="button"
role="radio"
aria-checked={active}
tabIndex={active ? 0 : -1}
onClick={() => setCount(c)}
onKeyDown={(event) => handleKeyNav(event, i)}
className={`h-8 w-8 rounded-full text-sm font-medium transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-100 dark:focus-visible:ring-offset-slate-800 ${
active
? "bg-white text-indigo-600 shadow-sm dark:bg-slate-950 dark:text-indigo-300"
: "text-slate-500 hover:text-slate-800 dark:text-slate-400 dark:hover:text-slate-100"
}`}
>
{c}
</button>
);
})}
</div>
</div>
</div>
</motion.div>
</div>
</section>
);
}Dependencies
motion
Licence
Built by Web Innoventix. Free for personal and commercial use, no attribution required.
Built by Web Innoventix
Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.
Get a free quoteSimilar components
Browse all →
Spinner Set Loader
Originalset of circular spinners in sizes

Dots Loader
Originalbouncing dots loader

Bars Loader
Originalequalizer bars loader

Ring Loader
Originalring spinner loader

Pulse Loader
Originalpulsing circle loader

Orbit Loader
Originalorbiting dots loader

Wave Loader
Originalwave loader

Gradient Ring Loader
Originalgradient conic ring spinner

Dual Ring Loader
Originaldual counter-rotating rings

Square Loader
Originalmorphing square loader

Typing Loader
Originaltyping indicator loader

Page Overlay Loader
Originalfull-page loading overlay with progress

