Dots Loader
Original · freebouncing dots loader
byWeb InnoventixReact + Tailwind
loaddotsloaders
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-dots.jsonload-dots.tsx
"use client";
import { useEffect, useRef, useState } from "react";
import type { CSSProperties, KeyboardEvent, ReactNode } from "react";
import { motion, useReducedMotion } from "motion/react";
type Speed = "slow" | "normal" | "fast";
type CountKey = "3" | "4" | "5";
type Size = "sm" | "md" | "lg";
type ColorKey = "indigo" | "violet" | "sky" | "emerald" | "amber" | "rose";
const SPEEDS = [
{ value: "slow", label: "Slow" },
{ value: "normal", label: "Normal" },
{ value: "fast", label: "Fast" },
] as const satisfies readonly { value: Speed; label: string }[];
const COUNTS = [
{ value: "3", label: "3" },
{ value: "4", label: "4" },
{ value: "5", label: "5" },
] as const satisfies readonly { value: CountKey; label: string }[];
const SIZES = [
{ value: "sm", label: "S" },
{ value: "md", label: "M" },
{ value: "lg", label: "L" },
] as const satisfies readonly { value: Size; label: string }[];
const COLORS = [
{ key: "indigo", label: "Indigo", dot: "bg-indigo-500" },
{ key: "violet", label: "Violet", dot: "bg-violet-500" },
{ key: "sky", label: "Sky", dot: "bg-sky-500" },
{ key: "emerald", label: "Emerald", dot: "bg-emerald-500" },
{ key: "amber", label: "Amber", dot: "bg-amber-500" },
{ key: "rose", label: "Rose", dot: "bg-rose-500" },
] as const satisfies readonly { key: ColorKey; label: string; dot: string }[];
const DURATION: Record<Speed, string> = { slow: "1.5s", normal: "0.95s", fast: "0.6s" };
const STAGGER: Record<Speed, string> = { slow: "0.16s", normal: "0.1s", fast: "0.07s" };
const DOT_SIZE: Record<Size, string> = { sm: "9px", md: "13px", lg: "18px" };
const GAP: Record<Size, string> = { sm: "7px", md: "10px", lg: "14px" };
const DOT_CLASS: Record<ColorKey, string> = {
indigo: "bg-indigo-500",
violet: "bg-violet-500",
sky: "bg-sky-500",
emerald: "bg-emerald-500",
amber: "bg-amber-500",
rose: "bg-rose-500",
};
const SPEED_TXT: Record<Speed, string> = { slow: "slow", normal: "normal", fast: "fast" };
const SIZE_TXT: Record<Size, string> = { sm: "small", md: "medium", lg: "large" };
const FOCUS =
"focus-visible: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-950";
function useRoving<T extends string>(values: readonly T[], value: T, onChange: (v: T) => void) {
const refs = useRef<(HTMLButtonElement | null)[]>([]);
const onKeyDown = (e: KeyboardEvent<HTMLButtonElement>, i: number) => {
let next = i;
switch (e.key) {
case "ArrowRight":
case "ArrowDown":
next = (i + 1) % values.length;
break;
case "ArrowLeft":
case "ArrowUp":
next = (i - 1 + values.length) % values.length;
break;
case "Home":
next = 0;
break;
case "End":
next = values.length - 1;
break;
default:
return;
}
e.preventDefault();
onChange(values[next]);
refs.current[next]?.focus();
};
return { refs, onKeyDown };
}
function Segmented<T extends string>({
legend,
value,
onChange,
options,
}: {
legend: string;
value: T;
onChange: (v: T) => void;
options: readonly { value: T; label: string }[];
}) {
const values = options.map((o) => o.value);
const { refs, onKeyDown } = useRoving(values, value, onChange);
return (
<div
role="radiogroup"
aria-label={legend}
className="inline-flex rounded-lg bg-slate-100 p-1 dark:bg-slate-800/80"
>
{options.map((o, i) => {
const sel = o.value === value;
return (
<button
key={o.value}
ref={(el) => {
refs.current[i] = el;
}}
type="button"
role="radio"
aria-checked={sel}
tabIndex={sel ? 0 : -1}
onClick={() => onChange(o.value)}
onKeyDown={(e) => onKeyDown(e, i)}
className={`min-w-10 rounded-md px-3 py-1.5 text-sm font-medium transition ${FOCUS} ${
sel
? "bg-white text-slate-900 shadow-sm dark:bg-slate-950 dark:text-white"
: "text-slate-500 hover:text-slate-800 dark:text-slate-400 dark:hover:text-slate-200"
}`}
>
{o.label}
</button>
);
})}
</div>
);
}
function ColorPicker({ value, onChange }: { value: ColorKey; onChange: (v: ColorKey) => void }) {
const keys = COLORS.map((c) => c.key);
const { refs, onKeyDown } = useRoving(keys, value, onChange);
return (
<div role="radiogroup" aria-label="Dot color" className="flex flex-wrap items-center gap-2">
{COLORS.map((c, i) => {
const sel = c.key === value;
return (
<button
key={c.key}
ref={(el) => {
refs.current[i] = el;
}}
type="button"
role="radio"
aria-checked={sel}
aria-label={c.label}
tabIndex={sel ? 0 : -1}
onClick={() => onChange(c.key)}
onKeyDown={(e) => onKeyDown(e, i)}
className={`grid h-9 w-9 place-items-center rounded-full transition ${FOCUS} ${
sel
? "ring-2 ring-slate-900 ring-offset-2 ring-offset-white dark:ring-white dark:ring-offset-slate-950"
: "hover:scale-110"
}`}
>
<span className={`h-5 w-5 rounded-full ${c.dot}`} />
</button>
);
})}
</div>
);
}
function DotsLoader({
count,
colorClass,
dotSize,
gap,
duration,
stagger,
playing,
label,
decorative = false,
}: {
count: number;
colorClass: string;
dotSize: string;
gap: string;
duration: string;
stagger: string;
playing: boolean;
label: string;
decorative?: boolean;
}) {
const vars = {
"--ldots-size": dotSize,
"--ldots-duration": duration,
"--ldots-stagger": stagger,
"--ldots-play": playing ? "running" : "paused",
gap,
} as CSSProperties;
return (
<div
{...(decorative
? { "aria-hidden": true }
: { role: "status", "aria-live": "polite" as const })}
className="inline-flex items-end"
style={vars}
>
{Array.from({ length: count }).map((_, i) => (
<span
key={i}
className={`ldots-dot rounded-full ${colorClass}`}
style={{ animationDelay: `calc(${i} * var(--ldots-stagger))` }}
/>
))}
{!decorative && <span className="sr-only">{label}</span>}
</div>
);
}
function PlayIcon() {
return (
<svg viewBox="0 0 20 20" aria-hidden="true" className="h-4 w-4 fill-current">
<path d="M6 4l10 6-10 6z" />
</svg>
);
}
function PauseIcon() {
return (
<svg viewBox="0 0 20 20" aria-hidden="true" className="h-4 w-4 fill-current">
<rect x="5" y="4" width="3.5" height="12" rx="1" />
<rect x="11.5" y="4" width="3.5" height="12" rx="1" />
</svg>
);
}
function CheckIcon() {
return (
<svg viewBox="0 0 20 20" aria-hidden="true" className="h-4 w-4">
<path
d="M4 10.5l4 4 8-9"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
function Field({ label, children }: { label: string; children: ReactNode }) {
return (
<div>
<p className="mb-2 text-[11px] font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">
{label}
</p>
{children}
</div>
);
}
type SaveState = "idle" | "saving" | "done";
export default function LoadDots() {
const reduce = useReducedMotion();
const [playing, setPlaying] = useState(true);
const [speed, setSpeed] = useState<Speed>("normal");
const [count, setCount] = useState<CountKey>("3");
const [size, setSize] = useState<Size>("md");
const [color, setColor] = useState<ColorKey>("indigo");
const [save, setSave] = useState<SaveState>("idle");
const timers = useRef<number[]>([]);
useEffect(() => {
const list = timers.current;
return () => list.forEach((t) => clearTimeout(t));
}, []);
function runSave() {
if (save !== "idle") return;
setSave("saving");
timers.current.push(
window.setTimeout(() => {
setSave("done");
timers.current.push(window.setTimeout(() => setSave("idle"), 1600));
}, 1900),
);
}
function reset() {
setPlaying(true);
setSpeed("normal");
setCount("3");
setSize("md");
setColor("indigo");
}
const nDots = Number(count);
const rise = reduce
? undefined
: {
initial: { opacity: 0, y: 14 },
whileInView: { opacity: 1, y: 0 },
viewport: { once: true, amount: 0.25 },
transition: { duration: 0.5, ease: "easeOut" },
};
return (
<section className="relative w-full bg-white px-4 py-20 dark:bg-slate-950 sm:px-6 sm:py-28">
<style>{`
.ldots-dot {
display: inline-block;
width: var(--ldots-size);
height: var(--ldots-size);
animation-name: ldots-bounce;
animation-duration: var(--ldots-duration);
animation-timing-function: cubic-bezier(0.34, 0.15, 0.3, 1);
animation-iteration-count: infinite;
animation-play-state: var(--ldots-play, running);
}
@keyframes ldots-bounce {
0%, 70%, 100% { transform: translateY(0) scale(0.7); opacity: 0.4; }
35% { transform: translateY(-110%) scale(1); opacity: 1; }
}
@keyframes ldots-fade {
0%, 100% { opacity: 0.3; }
50% { opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
.ldots-dot {
animation-name: ldots-fade;
animation-timing-function: ease-in-out;
transform: none;
}
}
`}</style>
<div className="mx-auto max-w-3xl">
<div className="mb-8">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-indigo-600 dark:text-indigo-400">
Loaders
</p>
<h2 className="mt-2 text-2xl font-bold tracking-tight text-slate-900 dark:text-white sm:text-3xl">
Bouncing dots
</h2>
<p className="mt-3 max-w-xl text-sm leading-relaxed text-slate-600 dark:text-slate-300">
A configurable loading indicator with real, keyboard-accessible controls, live status
announcements for assistive tech, and a reduced-motion fallback. Tune the speed, dot
count, size, and color, then drop it into any pending state.
</p>
</div>
<motion.div
{...(rise ?? {})}
className="rounded-3xl border border-slate-200 bg-white p-6 shadow-sm dark:border-slate-800 dark:bg-slate-900 sm:p-8"
>
<div className="rounded-2xl border border-slate-200 bg-gradient-to-b from-slate-50 to-white p-10 dark:border-slate-800 dark:from-slate-900 dark:to-slate-950">
<div className="flex min-h-[120px] items-center justify-center">
<DotsLoader
count={nDots}
colorClass={DOT_CLASS[color]}
dotSize={DOT_SIZE[size]}
gap={GAP[size]}
duration={DURATION[speed]}
stagger={STAGGER[speed]}
playing={playing}
label={playing ? "Loading content" : "Loading paused"}
/>
</div>
<p className="mt-6 text-center text-xs text-slate-500 dark:text-slate-400">
{nDots} dots · {SPEED_TXT[speed]} speed · {SIZE_TXT[size]} ·{" "}
{playing ? "running" : "paused"}
</p>
</div>
<div className="mt-8 grid gap-6 sm:grid-cols-2">
<Field label="Speed">
<Segmented legend="Speed" value={speed} onChange={setSpeed} options={SPEEDS} />
</Field>
<Field label="Dots">
<Segmented legend="Dots" value={count} onChange={setCount} options={COUNTS} />
</Field>
<Field label="Size">
<Segmented legend="Size" value={size} onChange={setSize} options={SIZES} />
</Field>
<Field label="Color">
<ColorPicker value={color} onChange={setColor} />
</Field>
</div>
<div className="mt-8 flex flex-wrap items-center gap-3 border-t border-slate-200 pt-6 dark:border-slate-800">
<button
type="button"
aria-pressed={playing}
onClick={() => setPlaying((p) => !p)}
className={`inline-flex items-center gap-2 rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-indigo-500 ${FOCUS}`}
>
{playing ? <PauseIcon /> : <PlayIcon />}
{playing ? "Pause" : "Play"}
</button>
<button
type="button"
onClick={reset}
className={`rounded-lg border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 transition hover:bg-slate-50 dark:border-slate-700 dark:text-slate-200 dark:hover:bg-slate-800 ${FOCUS}`}
>
Reset
</button>
<p className="ml-auto text-xs text-slate-400 dark:text-slate-500">
Arrow keys switch options
</p>
</div>
</motion.div>
<motion.div
{...(rise ?? {})}
className="mt-6 rounded-2xl border border-slate-200 bg-white p-6 dark:border-slate-800 dark:bg-slate-900"
>
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h3 className="text-sm font-semibold text-slate-900 dark:text-white">
Deployment settings
</h3>
<p className="mt-1 max-w-sm text-sm text-slate-500 dark:text-slate-400">
Saving triggers a rebuild. The button holds a pending state with the same loader
while the request is in flight.
</p>
</div>
<button
type="button"
onClick={runSave}
disabled={save !== "idle"}
className={`inline-flex min-w-[9.5rem] items-center justify-center gap-2 rounded-lg px-5 py-2.5 text-sm font-semibold transition ${FOCUS} ${
save === "done"
? "bg-emerald-600 text-white"
: "bg-slate-900 text-white hover:bg-slate-800 dark:bg-slate-800 dark:hover:bg-slate-700"
} disabled:cursor-default`}
>
{save === "idle" && "Save changes"}
{save === "saving" && (
<>
<DotsLoader
decorative
count={3}
colorClass="bg-white"
dotSize="6px"
gap="4px"
duration="0.9s"
stagger="0.1s"
playing
label=""
/>
<span>Saving</span>
</>
)}
{save === "done" && (
<>
<CheckIcon />
<span>Saved</span>
</>
)}
</button>
</div>
<span aria-live="polite" className="sr-only">
{save === "saving"
? "Saving your changes"
: save === "done"
? "Changes saved"
: ""}
</span>
</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

Bars Loader
Originalequalizer bars loader

Ring Loader
Originalring spinner loader

Pulse Loader
Originalpulsing circle loader

Orbit Loader
Originalorbiting dots loader

Wave Loader
Originalwave loader

Bounce Loader
Originalbouncing ball 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

