Web InnoventixFreeCode

Square Loader

Original · free

morphing square loader

byWeb InnoventixReact + Tailwind
loadsquareloaders
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-square.json
load-square.tsx
"use client";

import {
  useEffect,
  useId,
  useRef,
  useState,
  type CSSProperties,
  type KeyboardEvent,
} from "react";
import { useReducedMotion } from "motion/react";

type Speed = 0.5 | 1 | 2;
type DemoState = "idle" | "loading" | "done";

const SPEEDS: readonly Speed[] = [0.5, 1, 2];
const SPEED_LABEL: Record<Speed, string> = {
  0.5: "0.5×",
  1: "1×",
  2: "2×",
};

export default function LoadSquare() {
  const reduce = useReducedMotion();
  const groupId = useId();

  const [isRunning, setIsRunning] = useState<boolean>(true);
  const [speed, setSpeed] = useState<Speed>(1);
  const [demoState, setDemoState] = useState<DemoState>("idle");
  const [progress, setProgress] = useState<number>(0);

  const isRunningRef = useRef<boolean>(isRunning);
  const speedRefs = useRef<Array<HTMLButtonElement | null>>([]);

  useEffect(() => {
    isRunningRef.current = isRunning;
  }, [isRunning]);

  // Drive the simulated load. Ticks pause while the loader is paused.
  useEffect(() => {
    if (demoState !== "loading") return;
    const id = window.setInterval(
      () => {
        if (!isRunningRef.current) return;
        setProgress((p) => {
          const step = reduce ? 25 : Math.round(6 + Math.random() * 11);
          return Math.min(100, p + step);
        });
      },
      reduce ? 420 : 240,
    );
    return () => window.clearInterval(id);
  }, [demoState, reduce]);

  useEffect(() => {
    if (demoState === "loading" && progress >= 100) setDemoState("done");
  }, [progress, demoState]);

  const runDemo = () => {
    setProgress(0);
    setDemoState("loading");
    setIsRunning(true);
  };

  const onSpeedKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
    const current = SPEEDS.indexOf(speed);
    let next = current;
    if (e.key === "ArrowRight" || e.key === "ArrowDown")
      next = (current + 1) % SPEEDS.length;
    else if (e.key === "ArrowLeft" || e.key === "ArrowUp")
      next = (current - 1 + SPEEDS.length) % SPEEDS.length;
    else if (e.key === "Home") next = 0;
    else if (e.key === "End") next = SPEEDS.length - 1;
    else return;
    e.preventDefault();
    setSpeed(SPEEDS[next]);
    speedRefs.current[next]?.focus();
  };

  const still = !isRunning || reduce === true;
  const durationVar = {
    "--loadsq-duration": `${(2.4 / speed).toFixed(2)}s`,
  } as CSSProperties;

  const statusLive =
    demoState === "loading"
      ? "Loading project assets."
      : demoState === "done"
        ? "Finished. All project assets loaded."
        : isRunning
          ? "Working. Indexing your workspace."
          : "Paused. Press play to resume the loader.";

  const btnBase =
    "inline-flex items-center justify-center gap-2 rounded-xl text-sm font-semibold transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-950";

  return (
    <section className="relative w-full overflow-hidden bg-slate-50 px-6 py-20 text-slate-900 dark:bg-slate-950 dark:text-slate-100 sm:py-28">
      <style>{`
        @keyframes loadsq-morph {
          0%   { border-radius: 26%; transform: rotate(0deg) scale(1); }
          20%  { border-radius: 50%; transform: rotate(72deg) scale(.84); }
          40%  { border-radius: 15%; transform: rotate(144deg) scale(1.05); }
          60%  { border-radius: 50%; transform: rotate(216deg) scale(.84); }
          80%  { border-radius: 20%; transform: rotate(288deg) scale(1.05); }
          100% { border-radius: 26%; transform: rotate(360deg) scale(1); }
        }
        @keyframes loadsq-glow {
          0%, 100% { opacity: .40; transform: scale(.9); }
          50%      { opacity: .80; transform: scale(1.1); }
        }
        .loadsq-shape {
          border-radius: 26%;
          animation: loadsq-morph var(--loadsq-duration, 2.4s) cubic-bezier(.66, 0, .34, 1) infinite;
          will-change: transform, border-radius;
        }
        .loadsq-shape-2 { animation-delay: calc(var(--loadsq-duration, 2.4s) * -0.14); }
        .loadsq-shape-3 { animation-delay: calc(var(--loadsq-duration, 2.4s) * -0.28); }
        .loadsq-glow {
          animation: loadsq-glow var(--loadsq-duration, 2.4s) ease-in-out infinite;
          will-change: transform, opacity;
        }
        [data-loadsq-still="true"] .loadsq-shape,
        [data-loadsq-still="true"] .loadsq-glow {
          animation-play-state: paused;
        }
        @media (prefers-reduced-motion: reduce) {
          .loadsq-shape, .loadsq-glow { animation: none !important; }
        }
      `}</style>

      {/* Atmosphere */}
      <div
        aria-hidden
        className="pointer-events-none absolute -top-24 left-1/2 h-72 w-72 -translate-x-1/2 rounded-full bg-indigo-300/40 blur-3xl dark:bg-indigo-600/20"
      />
      <div
        aria-hidden
        className="pointer-events-none absolute -bottom-32 right-[-4rem] h-72 w-72 rounded-full bg-violet-300/40 blur-3xl dark:bg-fuchsia-700/15"
      />

      <div className="relative mx-auto max-w-3xl">
        <header className="mx-auto max-w-xl text-center">
          <span className="inline-flex items-center gap-2 rounded-full border border-slate-200 bg-white/70 px-3 py-1 text-xs font-medium uppercase tracking-[0.18em] text-indigo-600 backdrop-blur dark:border-slate-800 dark:bg-slate-900/60 dark:text-indigo-300">
            <span
              aria-hidden
              className="h-1.5 w-1.5 rounded-full bg-indigo-500 dark:bg-indigo-400"
            />
            Loaders
          </span>
          <h2 className="mt-5 text-3xl font-semibold tracking-tight sm:text-4xl">
            Morphing square loader
          </h2>
          <p className="mt-3 text-base leading-relaxed text-slate-600 dark:text-slate-400">
            A calm activity indicator that folds a square through circle and
            back on pure GPU transforms. Drive it with real controls — pause it,
            change its tempo, or run a simulated asset load.
          </p>
        </header>

        {/* Stage */}
        <div className="mt-12 rounded-3xl border border-slate-200 bg-white/80 p-8 shadow-xl shadow-slate-900/5 backdrop-blur dark:border-slate-800 dark:bg-slate-900/60 dark:shadow-black/40 sm:p-12">
          <div className="flex min-h-[16rem] items-center justify-center">
            <div
              aria-hidden
              data-loadsq-still={String(still)}
              className="relative h-44 w-44"
              style={durationVar}
            >
              <span className="loadsq-glow absolute inset-0 m-auto h-40 w-40 rounded-[42%] bg-gradient-to-br from-indigo-400 to-violet-500 blur-2xl dark:from-indigo-500 dark:to-fuchsia-500" />
              <span className="loadsq-shape absolute inset-0 m-auto h-32 w-32 bg-gradient-to-br from-indigo-500 to-violet-600 shadow-lg shadow-indigo-500/30 dark:from-indigo-400 dark:to-violet-500" />
              <span className="loadsq-shape loadsq-shape-2 absolute inset-0 m-auto h-24 w-24 bg-gradient-to-br from-white/80 to-white/20 dark:from-white/25 dark:to-white/[0.04]" />
              <span className="loadsq-shape loadsq-shape-3 absolute inset-0 m-auto h-16 w-16 bg-gradient-to-br from-indigo-600 to-violet-700 dark:from-indigo-300 dark:to-violet-400" />
            </div>
          </div>

          {/* Live status */}
          <p
            role="status"
            aria-live="polite"
            className="mt-2 text-center text-sm font-medium text-slate-600 dark:text-slate-300"
          >
            {statusLive}
          </p>

          {/* Determinate progress (only during / after a demo load) */}
          {demoState !== "idle" && (
            <div className="mx-auto mt-6 max-w-md">
              <div className="mb-2 flex items-center justify-between text-xs font-medium text-slate-500 dark:text-slate-400">
                <span>{demoState === "done" ? "Complete" : "Loading assets"}</span>
                <span className="tabular-nums text-slate-700 dark:text-slate-200">
                  {progress}%
                </span>
              </div>
              <div
                role="progressbar"
                aria-label="Project asset load progress"
                aria-valuemin={0}
                aria-valuemax={100}
                aria-valuenow={progress}
                aria-valuetext={`${progress} percent`}
                className="h-2.5 w-full overflow-hidden rounded-full bg-slate-200 dark:bg-slate-800"
              >
                <div
                  className="h-full rounded-full bg-gradient-to-r from-indigo-500 to-violet-500 transition-[width] duration-300 ease-out dark:from-indigo-400 dark:to-violet-400"
                  style={{ width: `${progress}%` }}
                />
              </div>
            </div>
          )}

          {/* Controls */}
          <div className="mt-9 flex flex-col items-center gap-5 border-t border-slate-200 pt-8 dark:border-slate-800 sm:flex-row sm:flex-wrap sm:justify-center">
            <button
              type="button"
              onClick={() => setIsRunning((v) => !v)}
              aria-pressed={isRunning}
              className={`${btnBase} bg-indigo-600 px-5 py-2.5 text-white hover:bg-indigo-500 active:bg-indigo-700 dark:bg-indigo-500 dark:hover:bg-indigo-400`}
            >
              {isRunning ? (
                <svg
                  aria-hidden
                  viewBox="0 0 24 24"
                  className="h-4 w-4"
                  fill="currentColor"
                >
                  <rect x="6" y="5" width="4" height="14" rx="1" />
                  <rect x="14" y="5" width="4" height="14" rx="1" />
                </svg>
              ) : (
                <svg
                  aria-hidden
                  viewBox="0 0 24 24"
                  className="h-4 w-4"
                  fill="currentColor"
                >
                  <path d="M8 5.14v13.72a1 1 0 0 0 1.53.85l10.79-6.86a1 1 0 0 0 0-1.7L9.53 4.29A1 1 0 0 0 8 5.14Z" />
                </svg>
              )}
              {isRunning ? "Pause" : "Play"}
            </button>

            <button
              type="button"
              onClick={runDemo}
              className={`${btnBase} border border-slate-300 bg-white px-5 py-2.5 text-slate-800 hover:border-slate-400 hover:bg-slate-50 active:bg-slate-100 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-100 dark:hover:border-slate-600 dark:hover:bg-slate-700`}
            >
              <svg
                aria-hidden
                viewBox="0 0 24 24"
                className="h-4 w-4"
                fill="none"
                stroke="currentColor"
                strokeWidth="2"
                strokeLinecap="round"
                strokeLinejoin="round"
              >
                <path d="M21 12a9 9 0 1 1-6.22-8.56" />
                <path d="M21 3v6h-6" />
              </svg>
              {demoState === "loading"
                ? "Restart load"
                : demoState === "done"
                  ? "Run again"
                  : "Simulate load"}
            </button>

            {/* Speed segmented radiogroup */}
            <div className="flex items-center gap-3">
              <span
                id={`${groupId}-label`}
                className="text-sm font-medium text-slate-500 dark:text-slate-400"
              >
                Speed
              </span>
              <div
                role="radiogroup"
                aria-labelledby={`${groupId}-label`}
                onKeyDown={onSpeedKeyDown}
                className="inline-flex rounded-xl border border-slate-300 bg-slate-100 p-1 dark:border-slate-700 dark:bg-slate-800"
              >
                {SPEEDS.map((s, i) => {
                  const active = s === speed;
                  return (
                    <button
                      key={s}
                      ref={(el) => {
                        speedRefs.current[i] = el;
                      }}
                      type="button"
                      role="radio"
                      aria-checked={active}
                      tabIndex={active ? 0 : -1}
                      onClick={() => setSpeed(s)}
                      className={`rounded-lg px-3 py-1.5 text-sm font-semibold tabular-nums transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 ${
                        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"
                      }`}
                    >
                      {SPEED_LABEL[s]}
                    </button>
                  );
                })}
              </div>
            </div>
          </div>
        </div>

        {/* Spec chips */}
        <ul className="mt-8 flex flex-wrap items-center justify-center gap-2.5 text-xs">
          {[
            "GPU transform + border-radius only",
            "Honors prefers-reduced-motion",
            "Keyboard-driven, ARIA progressbar",
          ].map((chip) => (
            <li
              key={chip}
              className="rounded-full border border-slate-200 bg-white/70 px-3 py-1.5 font-medium text-slate-600 backdrop-blur dark:border-slate-800 dark:bg-slate-900/50 dark:text-slate-400"
            >
              {chip}
            </li>
          ))}
        </ul>
      </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 →