Web InnoventixFreeCode

Bounce Loader

Original · free

bouncing ball loader

byWeb InnoventixReact + Tailwind
loadbounceloaders
Open

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.json
load-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 quote

Similar components

Browse all →