Web InnoventixFreeCode

Dots Loader

Original · free

bouncing dots loader

byWeb InnoventixReact + Tailwind
loaddotsloaders
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-dots.json
load-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 &middot; {SPEED_TXT[speed]} speed &middot; {SIZE_TXT[size]} &middot;{" "}
              {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 quote

Similar components

Browse all →