Web Animations Easy
Morphing Loader
A compact SVG loader alternates between a ring, square, and diamond using SMIL with a JS pause toggle.
Open in Lab
MCP
html css javascript react
Targets: TS JS HTML React
Code
:root {
color-scheme: dark;
--bg: #08090d;
--line: #262a38;
--fg: #e8eaf2;
--muted: #8b91a6;
--accent: #7dd3fc;
}
*, *::before, *::after { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
background: radial-gradient(120% 90% at 50% 0%, #161a26 0%, var(--bg) 60%);
color: var(--fg);
font: 15px/1.5 ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
}
button { font: inherit; }
.demo {
min-height: 100vh;
display: grid;
place-items: center;
padding: clamp(1rem, 4vw, 3rem);
}
.loader-card {
width: min(420px, 100%);
padding: 2rem 1.75rem 1.5rem;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.045), rgba(255, 255, 255, 0.012));
border: 1px solid var(--line);
border-radius: 20px;
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.55);
text-align: center;
}
.loader-title {
margin: 0;
font-size: 1.15rem;
letter-spacing: -0.01em;
}
.loader-sub {
margin: 0.35rem 0 0;
font-size: 0.85rem;
color: var(--muted);
}
.loader-stage {
display: grid;
place-items: center;
margin: 1.5rem 0 1.25rem;
padding: 1.25rem;
border-radius: 16px;
background:
radial-gradient(60% 60% at 50% 50%, rgba(125, 211, 252, 0.1), transparent 70%),
#0c0e15;
border: 1px solid var(--line);
}
.morph {
overflow: visible;
max-width: 100%;
height: auto;
filter: drop-shadow(0 0 14px rgba(125, 211, 252, 0.35));
transition: filter 0.3s ease, opacity 0.3s ease;
}
.demo[data-paused="true"] .morph {
filter: none;
opacity: 0.7;
}
.loader-controls {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.btn {
appearance: none;
min-width: 100px;
padding: 0.55rem 1rem;
font-size: 0.9rem;
font-weight: 600;
color: #08090d;
background: linear-gradient(135deg, #7dd3fc, #c084fc);
border: 0;
border-radius: 10px;
cursor: pointer;
transition: transform 0.15s ease, filter 0.15s ease;
}
.btn:hover { filter: brightness(1.08); }
.btn:active { transform: translateY(1px); }
.btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
.loader-status {
margin: 0;
font-size: 0.8rem;
color: var(--muted);
}
.loader-note {
margin: 1rem 0 0;
font-size: 0.72rem;
color: #6b7189;
}
@media (prefers-reduced-motion: reduce) {
.morph { filter: none; }
.btn { transition: none; }
}// Morphing Loader — pause/resume SMIL via the SVG animation timeline.
(function () {
const demo = document.querySelector('[data-demo="Morphing Loader"]');
const svg = document.querySelector(".morph");
const btn = document.getElementById("loader-toggle");
const status = document.getElementById("loader-status");
if (!demo || !svg || !btn || !status) return;
const SHAPES = ["ring", "square", "diamond"];
const CYCLE = 3.6; // must match the SMIL `dur`
let paused = false;
function currentShape() {
// getCurrentTime() is the SVG document timeline in seconds; it stops while paused.
const t = typeof svg.getCurrentTime === "function" ? svg.getCurrentTime() : 0;
const phase = ((t % CYCLE) + CYCLE) % CYCLE;
return SHAPES[Math.floor((phase / CYCLE) * SHAPES.length) % SHAPES.length];
}
function render() {
demo.dataset.paused = String(paused);
btn.textContent = paused ? "Play" : "Pause";
btn.setAttribute("aria-pressed", String(paused));
status.textContent = paused
? "Paused on " + currentShape()
: "Animating → " + currentShape();
}
function setPaused(next) {
paused = next;
if (paused) svg.pauseAnimations();
else svg.unpauseAnimations();
render();
}
btn.addEventListener("click", function () {
setPaused(!paused);
});
// Keep the live status readable without spamming assistive tech.
setInterval(function () {
if (!paused) render();
}, 600);
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)");
function applyMotionPreference(mq) {
if (mq.matches) {
svg.setCurrentTime(0); // freeze on the ring keyframe
setPaused(true);
}
}
if (typeof reduce.addEventListener === "function") {
reduce.addEventListener("change", applyMotionPreference);
}
applyMotionPreference(reduce);
render();
})();<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Morphing Loader</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<main class="demo" data-demo="Morphing Loader">
<section class="loader-card">
<h1 class="loader-title">Morphing Loader</h1>
<p class="loader-sub">Ring → square → diamond, morphed with SMIL.</p>
<div class="loader-stage">
<svg
class="morph"
viewBox="0 0 100 100"
width="170"
height="170"
role="img"
aria-labelledby="morph-title"
>
<title id="morph-title">Loading indicator</title>
<defs>
<linearGradient id="morphGrad" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#7dd3fc" />
<stop offset="100%" stop-color="#c084fc" />
</linearGradient>
</defs>
<g id="morphSpinGroup">
<animateTransform
id="morphSpin"
attributeName="transform"
type="rotate"
dur="3.6s"
repeatCount="indefinite"
calcMode="spline"
keyTimes="0;0.333;0.666;1"
keySplines="0.65 0 0.35 1;0.65 0 0.35 1;0.65 0 0.35 1"
values="0 50 50;120 50 50;240 50 50;360 50 50"
/>
<path
id="morphPath"
fill="none"
stroke="url(#morphGrad)"
stroke-width="6"
stroke-linecap="round"
stroke-linejoin="round"
d="M50,14 C69.9,14 86,30.1 86,50 C86,69.9 69.9,86 50,86 C30.1,86 14,69.9 14,50 C14,30.1 30.1,14 50,14 Z"
>
<animate
id="morphAnim"
attributeName="d"
dur="3.6s"
repeatCount="indefinite"
calcMode="spline"
keyTimes="0;0.333;0.666;1"
keySplines="0.65 0 0.35 1;0.65 0 0.35 1;0.65 0 0.35 1"
values="M50,14 C69.9,14 86,30.1 86,50 C86,69.9 69.9,86 50,86 C30.1,86 14,69.9 14,50 C14,30.1 30.1,14 50,14 Z;M18,18 C29,18 71,18 82,18 C82,29 82,71 82,82 C71,82 29,82 18,82 C18,71 18,29 18,18 Z;M50,10 C63,23 77,37 90,50 C77,63 63,77 50,90 C37,77 23,63 10,50 C23,37 37,23 50,10 Z;M50,14 C69.9,14 86,30.1 86,50 C86,69.9 69.9,86 50,86 C30.1,86 14,69.9 14,50 C14,30.1 30.1,14 50,14 Z"
/>
</path>
</g>
</svg>
</div>
<div class="loader-controls">
<button id="loader-toggle" class="btn" type="button" aria-pressed="false">
Pause
</button>
<p class="loader-status" id="loader-status" role="status">Animating</p>
</div>
<p class="loader-note">
Reduced-motion visitors get a static ring; the toggle still works.
</p>
</section>
</main>
<script src="script.js"></script>
</body>
</html>import { useState } from "react";
export function SvgMorphLoader() {
const [items, setItems] = useState(["Alpha", "Beta", "Gamma"]);
return (
<section className="demo">
<h2>Morphing Loader</h2>
<p>Vanilla behavior maps cleanly to Pointer Events and DOM state.</p>
<div className="stack">
{items.map((item, index) => (
<button
className="item"
key={item}
onClick={() => setItems([...items.slice(0, index), ...items.slice(index + 1), item])}
>
{item}
</button>
))}
</div>
</section>
);
}Morphing Loader
A compact SVG loader alternates between a ring, square, and diamond using SMIL with a JS pause toggle.
Support notes
SVG animation can be driven by SMIL, CSS, or JavaScript. Keep path point counts compatible for reliable morphing and provide a non-animated state for reduced motion.
Included demo
- Vanilla HTML, CSS, and JavaScript with zero external dependencies.
- React equivalent using the same interaction model.
- A Lab route for trying the behavior in isolation.
Integration checklist
Keep state updates separate from presentation, preserve semantic labels, and add persistence or server callbacks at the boundary where your product needs them.