---
name: morphing-loader
description: Use when you want "Modern, playful, polished" - Uses shapes that transform while waiting.
---

# Morphing Loader

> **Category:** Loading / Transition  -  **Personality:** Modern, playful, polished
>
> **Best use:** Brand loaders, apps
>
> **Ratings:** Professional 4/5 - Casual 4/5 - Exotic 3/5 - Visual impact 4/5 - Difficulty 3/5 - Performance cost 2/5

## What it is

A morphing loader holds a user's attention during a wait by smoothly transforming **one shape** through a sequence of silhouettes — here circle → squircle → 4-point star → two organic blobs and back — instead of showing a static spinner. The morph is pure SVG: a single `<path>`'s `d` attribute is tweened between several shapes with a SMIL `<animate>`, while the gradient fill slowly rotates for a living colour. It reads modern, playful and polished, which is exactly the register a brand splash screen or app launch state wants. Because the whole effect is declarative SVG plus a few CSS keyframes, it loops forever at near-zero cost.

## Dependencies / CDN

**None** — the morph is pure SVG + CSS; a tiny vanilla-JS controller drives the looping progress %, the status copy and the pause/replay buttons. The demo's display/UI/mono fonts are optional:

```html
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Unbounded:wght@500;700;800&family=Familjen+Grotesk:wght@400;500;600&family=Spline+Sans+Mono:wght@400;500&display=swap" rel="stylesheet">
```

## HTML

The whole effect is this one SVG — the `<animate>` cycles `d` through equal-structure shape paths (first = last for a seamless loop). The loader scaffold (brand, progress, controls) is ordinary markup; a faux app-window bar is optional decoration.

```html
<div class="ml-stage" id="mlStage">
  <div class="ml-body">
    <div class="ml-markwrap">
      <span class="ml-halo" aria-hidden="true"></span>
      <svg class="ml-mark" id="mlMark" viewBox="0 0 220 220" role="img" aria-label="Loading animation">
        <defs>
          <linearGradient id="ml-grad" x1="0" y1="0" x2="1" y2="1">
            <stop offset="0" stop-color="#818cf8"/><stop offset=".5" stop-color="#22d3ee"/><stop offset="1" stop-color="#f472b6"/>
            <!-- living colour: rotate the gradient, not the fill -->
            <animateTransform attributeName="gradientTransform" type="rotate" from="0 .5 .5" to="360 .5 .5" dur="11s" repeatCount="indefinite"/>
          </linearGradient>
        </defs>
        <path class="ml-shape" fill="url(#ml-grad)"
          d="M198 110C198 130.74 186.89 157.56 172.23 172.23C157.56 186.89 130.74 198 110 198C89.26 198 62.44 186.89 47.77 172.23C33.11 157.56 22 130.74 22 110C22 89.26 33.11 62.44 47.77 47.77C62.44 33.11 89.26 22 110 22C130.74 22 157.56 33.11 172.23 47.77C186.89 62.44 198 89.26 198 110Z">
          <animate attributeName="d" dur="8.5s" repeatCount="indefinite" calcMode="spline"
            keyTimes="0;0.2;0.4;0.6;0.8;1"
            keySplines="0.45 0 0.55 1;0.45 0 0.55 1;0.45 0 0.55 1;0.45 0 0.55 1;0.45 0 0.55 1"
            values="M198 110C198 130.74 186.89 157.56 172.23 172.23C157.56 186.89 130.74 198 110 198C89.26 198 62.44 186.89 47.77 172.23C33.11 157.56 22 130.74 22 110C22 89.26 33.11 62.44 47.77 47.77C62.44 33.11 89.26 22 110 22C130.74 22 157.56 33.11 172.23 47.77C186.89 62.44 198 89.26 198 110Z;
                    M190 110C190 132.86 191.92 165.26 178.59 178.59C165.26 191.92 132.86 190 110 190C87.14 190 54.74 191.92 41.41 178.59C28.08 165.26 30 132.86 30 110C30 87.14 28.08 54.74 41.41 41.41C54.74 28.08 87.14 30 110 30C132.86 30 165.26 28.08 178.59 41.41C191.92 54.74 190 87.14 190 110Z;
                    M210 110C210 125.56 173.34 140 156.67 156.67C140 173.34 125.56 210 110 210C94.44 210 80 173.34 63.33 156.67C46.66 140 10 125.56 10 110C10 94.44 46.66 80 63.33 63.33C80 46.66 94.44 10 110 10C125.56 10 140 46.66 156.67 63.33C173.34 80 210 94.44 210 110Z;
                    M207 110C206.18 127.8 177.08 145.08 160.91 160.91C144.75 176.75 127.32 204.65 110 205C92.68 205.35 73.47 178.87 56.97 163.03C40.47 147.2 10.76 127.44 11 110C11.24 92.56 41.88 73.88 58.38 58.38C74.88 42.88 92.09 17.71 110 17C127.91 16.29 149.69 38.64 165.86 54.14C182.03 69.64 207.82 92.2 207 110Z;
                    M187 110C187.24 132.86 192.13 165.8 179.3 179.3C166.46 192.8 132.75 191.35 110 191C87.25 190.65 55.99 190.68 42.82 177.18C29.66 163.68 31.59 132.98 31 110C30.41 87.02 26.12 53.12 39.29 39.29C52.46 25.46 86.9 26.53 110 27C133.1 27.47 165.05 28.28 177.88 42.12C190.72 55.95 186.76 87.14 187 110Z;
                    M198 110C198 130.74 186.89 157.56 172.23 172.23C157.56 186.89 130.74 198 110 198C89.26 198 62.44 186.89 47.77 172.23C33.11 157.56 22 130.74 22 110C22 89.26 33.11 62.44 47.77 47.77C62.44 33.11 89.26 22 110 22C130.74 22 157.56 33.11 172.23 47.77C186.89 62.44 198 89.26 198 110Z"/>
        </path>
      </svg>
    </div>

    <h2 class="ml-brand">Morpho</h2>
    <p class="ml-tag">Your design studio is warming up</p>

    <div class="ml-prog">
      <div class="ml-track"><span class="ml-fill" id="mlFill"></span></div>
      <div class="ml-meta">
        <span class="ml-status" role="status" aria-live="polite"><b aria-hidden="true"></b><span id="mlStatusText">Initializing canvas&hellip;</span></span>
        <span class="ml-pct" id="mlPct">0%</span>
      </div>
    </div>

    <div class="ml-controls">
      <button class="ml-btn" id="mlPause" type="button" data-state="running" aria-label="Pause loader">
        <span class="ml-ico ml-ico-pause" aria-hidden="true"><svg viewBox="0 0 24 24"><rect x="6" y="5" width="4" height="14" rx="1.4"/><rect x="14" y="5" width="4" height="14" rx="1.4"/></svg></span>
        <span class="ml-ico ml-ico-play" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg></span>
        <span id="mlPauseLabel">Pause</span>
      </button>
      <button class="ml-btn" id="mlReplay" type="button">
        <span class="ml-ico" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M12 4V1L8 5l4 4V6a6 6 0 1 1-6 6H4a8 8 0 1 0 8-8z"/></svg></span>
        <span>Replay</span>
      </button>
    </div>
  </div>
</div>
```

## CSS

The morph needs **no CSS** — it lives entirely in the SVG above. CSS only sizes the mark, adds the glow, the slow spin, and styles the loader UI.

```css
/* shape mark: a gentle spin + a coloured drop-shadow lift; the morph is in the SVG */
.ml-markwrap{position:relative;width:clamp(146px,30vw,196px);aspect-ratio:1;display:grid;place-items:center}
.ml-mark{width:100%;height:100%;overflow:visible;position:relative;z-index:1;
  animation:ml-spin 26s linear infinite;filter:drop-shadow(0 16px 42px rgba(99,102,241,.5))}
.ml-halo{position:absolute;inset:-20%;border-radius:50%;z-index:0;filter:blur(22px);
  background:radial-gradient(closest-side,rgba(129,140,248,.55),rgba(34,211,238,.2) 56%,transparent 74%);
  animation:ml-pulse 4.6s ease-in-out infinite}
@keyframes ml-spin{to{transform:rotate(360deg)}}
@keyframes ml-pulse{0%,100%{transform:scale(.9);opacity:.78}50%{transform:scale(1.07);opacity:1}}

/* a dark, varied backdrop so the gradient + glow read */
.ml-stage{position:relative;border-radius:28px;overflow:hidden;min-height:620px;isolation:isolate;
  display:flex;flex-direction:column;color:#f3f0ff;
  background:
    radial-gradient(120% 78% at 50% 16%,rgba(99,102,241,.24),transparent 60%),
    radial-gradient(86% 70% at 82% 96%,rgba(244,114,182,.16),transparent 58%),
    linear-gradient(165deg,#0d0a1a,#08060f 72%)}
.ml-body{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;padding:52px 26px 64px}

/* progress bar with a sweeping sheen */
.ml-prog{width:min(330px,84vw)}
.ml-track{position:relative;height:7px;border-radius:999px;background:rgba(255,255,255,.09);overflow:hidden}
.ml-fill{position:absolute;inset:0 auto 0 0;width:0;border-radius:999px;
  background:linear-gradient(90deg,#818cf8,#22d3ee 55%,#f472b6);box-shadow:0 0 16px rgba(34,211,238,.5);
  transition:width .25s cubic-bezier(.4,0,.2,1)}
.ml-fill::after{content:"";position:absolute;inset:0;border-radius:inherit;
  background:linear-gradient(90deg,transparent,rgba(255,255,255,.55),transparent);
  transform:translateX(-100%);animation:ml-sheen 1.7s ease-in-out infinite}
@keyframes ml-sheen{0%{transform:translateX(-100%)}60%,100%{transform:translateX(260%)}}
.ml-pct{font-variant-numeric:tabular-nums}            /* numbers don't jitter as they count */

/* pill buttons + icon swap driven by data-state */
.ml-btn{display:inline-flex;align-items:center;gap:8px;border-radius:999px;padding:9px 17px;cursor:pointer;
  color:#f3f0ff;background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.14);
  transition:transform .18s,background .18s,border-color .18s,box-shadow .18s}
.ml-btn:hover{transform:translateY(-2px);background:rgba(255,255,255,.11);border-color:rgba(255,255,255,.3);box-shadow:0 12px 28px -14px rgba(99,102,241,.9)}
.ml-ico svg{width:14px;height:14px;display:block;fill:currentColor}
.ml-btn[data-state="running"] .ml-ico-play{display:none}
.ml-btn[data-state="paused"]  .ml-ico-pause{display:none}

/* pause control AND reduced-motion both freeze the CSS animations */
.ml-stage.is-paused .ml-mark,.ml-stage.is-paused .ml-halo,.ml-stage.is-paused .ml-fill::after{animation-play-state:paused}
@media (prefers-reduced-motion: reduce){
  .ml-mark,.ml-halo{animation:none}
  .ml-fill{transition:none}
  .ml-fill::after{display:none}
}
```

## JavaScript

Not required for the morph. This controller only loops the progress %, syncs the status copy, and wires Pause/Replay — and freezes everything when the user prefers reduced motion.

```js
(function(){
  var stage=document.getElementById('mlStage'), svg=document.getElementById('mlMark');
  if(!stage||!svg) return;
  var fill=document.getElementById('mlFill'), pct=document.getElementById('mlPct'),
      statusText=document.getElementById('mlStatusText'),
      pauseBtn=document.getElementById('mlPause'), pauseLabel=document.getElementById('mlPauseLabel'),
      replayBtn=document.getElementById('mlReplay');
  var steps=[{at:0,msg:'Initializing canvas…'},{at:24,msg:'Loading your brushes…'},
             {at:52,msg:'Shaping vector layers…'},{at:78,msg:'Optimizing assets…'},{at:100,msg:'Workspace ready'}];
  var progress=0, running=false, holding=false, timer=null;
  var reduce=window.matchMedia && matchMedia('(prefers-reduced-motion: reduce)').matches;

  function msgFor(p){var m=steps[0].msg;for(var i=0;i<steps.length;i++){if(p>=steps[i].at)m=steps[i].msg;}return m;}
  function render(){fill.style.width=progress+'%';pct.textContent=Math.round(progress)+'%';statusText.textContent=msgFor(progress);}
  function tick(){
    if(!running||holding) return;
    progress=Math.min(100, progress + (progress<88 ? 0.8+Math.random()*2.2 : 0.45));   // slow down near the end
    render();
    if(progress>=100){ holding=true; setTimeout(function(){ holding=false; if(running){progress=0;render();} },1100); }  // loop
  }
  function setRunning(on){
    running=on;
    if(on){ stage.classList.remove('is-paused'); svg.unpauseAnimations&&svg.unpauseAnimations();
            if(timer)clearInterval(timer); timer=setInterval(tick,90);
            pauseBtn.dataset.state='running'; pauseLabel.textContent='Pause'; }
    else  { stage.classList.add('is-paused'); svg.pauseAnimations&&svg.pauseAnimations();   // pauses ALL SMIL
            if(timer){clearInterval(timer);timer=null;}
            pauseBtn.dataset.state='paused'; pauseLabel.textContent='Resume'; }
  }
  pauseBtn.addEventListener('click', function(){ setRunning(!running); });
  replayBtn.addEventListener('click', function(){ progress=0; holding=false; render(); setRunning(true); });

  if(reduce){ progress=72; render(); setRunning(false); }  // freeze; user can opt in with Resume
  else { setRunning(true); }
})();
```

## How it works

- **`<animate attributeName="d">`** lists several path strings in `values`, separated by `;`, and SMIL interpolates between them on a loop. This is the entire morph — no JS, no library.
- **Same-structure paths are the trick.** Every shape is one closed loop of the *same number and type* of cubic-bézier commands (8 anchors → 8 `C` segments here). Only the coordinates differ, so the browser can tween point-to-point. They were generated by placing 8 points at varying radii and running a Catmull-Rom→bézier conversion, which guarantees the structure matches for every shape.
- **`calcMode="spline"` + `keySplines`** give each segment an ease-in-out instead of a robotic linear tween, so the shape "settles" into each silhouette. `keyTimes` spaces the shapes evenly across the duration.
- **Living colour without repainting the shape:** the fill is a `linearGradient` whose `gradientTransform` is rotated 0→360° by its own `animateTransform`. The path never changes colour values; the gradient just sweeps.
- **Depth dressing:** a blurred radial `.ml-halo` pulses behind the mark, a `drop-shadow()` lifts it, and a very slow CSS `rotate` spin keeps even the symmetric frames alive.
- **The loader context** (progress bar, counting %, cycling status line, pause/replay) is plain DOM updated by `setInterval`; it loops 0→100→0 so the demo is endlessly replayable.

## Customization

- **Different shapes:** swap the `values` paths. Keep every path the *same command count* (e.g. all `M … C×8 Z`). Generate them by sampling N points around a centre at chosen radii and converting with Catmull-Rom (square = far corners, star = far axes/near diagonals, blob = irregular radii).
- **Speed / rhythm:** `dur` on the `d` animate (morph pace), on the gradient `animateTransform` (colour pace), and the `ml-spin` duration (rotation). Slower = calmer/more premium.
- **Palette:** edit the three `<stop>` colours and the matching `.ml-fill` / `.ml-halo` gradients to rebrand in seconds.
- **Size:** the `clamp()` on `.ml-markwrap` width. **Number of shapes:** add/remove `values` entries (and matching `keyTimes`/`keySplines`), always repeating the first path last for a seamless loop.
- **Calmer version:** drop the `ml-spin` animation and keep only the morph + gradient.

## Accessibility & performance

- **Reduced motion:** the CSS `@media (prefers-reduced-motion: reduce)` stops the spin/halo/sheen, and the JS checks `matchMedia` *before* starting any loop — it freezes the SVG with `pauseAnimations()`, shows a static frame and a fixed %, and only animates if the user explicitly hits Resume.
- **Semantics:** the SVG carries `role="img"` + `aria-label="Loading animation"`; the progress line is a `role="status" aria-live="polite"` region so screen readers hear the changing stage text without it stealing focus.
- **Cheap:** pure SVG/CSS with no `<canvas>` and no per-frame `requestAnimationFrame` for the morph; the only JS timer is a 90 ms progress tick. Everything animated is `transform`/`opacity`/SVG geometry — GPU-friendly. `font-variant-numeric: tabular-nums` keeps the counter from jittering.

## Gotchas

- **Mismatched path structure = no tween.** If two `values` entries don't have the same number/type of commands, the browser silently falls back to discrete jumps (the shape snaps instead of morphing). Generate all shapes the same way.
- **First and last value must be identical** or the loop visibly "pops" on repeat.
- **No trailing `;`** in `values` — an empty final value breaks the animation. (Whitespace/newlines *between* values are fine; they're trimmed.)
- **`svg.pauseAnimations()` only pauses SMIL**, not CSS keyframes or your JS timer. Pause those yourself (here via the `.is-paused` class + `clearInterval`), or the shape freezes while the glow keeps pulsing.
- **Animate the gradient, not the fill.** Putting a CSS animation on the path's `fill` won't sweep a gradient; rotate `gradientTransform` inside the `<linearGradient>` instead.
- **Centre the shape in its viewBox** (here 110,110 of 220×220). If it's off-centre, the optional spin wobbles instead of rotating cleanly.
