Square Loader
Original · freemorphing square loader
byWeb InnoventixReact + Tailwind
loadsquareloaders
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.jsonload-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 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

Bounce Loader
Originalbouncing ball loader

Gradient Ring Loader
Originalgradient conic ring spinner

Dual Ring Loader
Originaldual counter-rotating rings

Typing Loader
Originaltyping indicator loader

Page Overlay Loader
Originalfull-page loading overlay with progress

