Morphing SVG Preloader
A glowing SVG form morphs circle → square → blob → star → mark while a hairline outline traces each new shape and a percentage climbs; at 100% the form scales 40× to fill the viewport and fades into the page.
- Preloaders
- gsap
- svg
- intermediate
- light perf
- mobile: full
- reduced-motion ✓
AI prompt
Recreate this effect with any AI tool
Paste into Claude, Cursor, v0, Lovable or Bolt. 1486 words of exact values — rewritten live as you tune the Playground.
### EFFECT: Morphing SVG Preloader
**What it does (visual):** The page opens on a solid dark screen. In the centre an off-white circle (about 26vw wide, max 220px) pops in from 60% scale with a slight overshoot, haloed by a soft glow in `#d4ff3a` (18px inner radius, a wider ~3× outer halo). A 1px `#d4ff3a` line sweeps once around a ring just outside the circle and vanishes. Beneath the form a large light serif percentage reads "0%" with the mono label "01 — CIRCLE" under it. Then, every 0.9 seconds, the filled form melts into the next shape — circle → rounded square → organic blob → soft four-point star → a leaf-like logo mark — each morph easing with `power2.inOut`. At the start of each step the outline (already in the shape of the *next* form) draws itself clockwise around the ring and then draws itself away again, so it always traces the form the fill is becoming. The percentage climbs 0 → 100 across the whole morph sequence (integer steps, tabular digits) and the label updates per step ("02 — SQUARE" … "05 — MARK"). At 100% the glow switches off, the outline disappears, the percentage fades up and away, and the mark swells 40× in 0.9s (slow then violently fast) until it covers the viewport; the loader then fades out over 0.6s revealing a page whose nav bar, accent eyebrow, giant serif headline "Shape is a verb.", paragraph and Replay button rise in with a stagger. Replay runs it all again.
**Tech stack:**
- GSAP 3.13+ with MorphSVGPlugin and DrawSVGPlugin (both free since 3.13).
- CDN: `gsap.min.js`, `MorphSVGPlugin.min.js`, `DrawSVGPlugin.min.js` from `https://cdn.jsdelivr.net/npm/gsap@3/dist/`.
- npm: `import gsap from "gsap"; import { MorphSVGPlugin } from "gsap/MorphSVGPlugin"; import { DrawSVGPlugin } from "gsap/DrawSVGPlugin"; gsap.registerPlugin(MorphSVGPlugin, DrawSVGPlugin);`
- Fonts: a variable serif for the percentage and headline (demo: Fraunces 300), a mono for labels.
**HTML structure:**
```html
<div class="msp">
<div class="msp__loader" aria-hidden="true">
<svg class="msp__svg" viewBox="0 0 200 200" width="200" height="200">
<defs>
<path id="msp-s1" d="M100,20 C144.2,20 180,55.8 180,100 C180,144.2 144.2,180 100,180 C55.8,180 20,144.2 20,100 C20,55.8 55.8,20 100,20 Z"/>
<path id="msp-s2" d="M62,30 H138 C155.7,30 170,44.3 170,62 V138 C170,155.7 155.7,170 138,170 H62 C44.3,170 30,155.7 30,138 V62 C30,44.3 44.3,30 62,30 Z"/>
<path id="msp-s3" d="M104,22 C146,16 182,54 178,98 C174,142 148,184 102,180 C56,176 18,146 22,100 C26,54 62,28 104,22 Z"/>
<path id="msp-s4" d="M100,18 C110,66 134,90 182,100 C134,110 110,134 100,182 C90,134 66,110 18,100 C66,90 90,66 100,18 Z"/>
<path id="msp-s5" d="M38,162 C38,86 92,38 162,38 C162,114 108,162 38,162 Z"/>
</defs>
<g class="msp__ring" transform="translate(100 100) scale(1.16) translate(-100 -100)">
<path class="msp__outline" d="(same d as #msp-s1)"/>
</g>
<path class="msp__shape" d="(same d as #msp-s1)"/>
</svg>
<div class="msp__meta">
<p class="msp__pct"><span class="msp__pct-num">0</span><span class="msp__pct-sign">%</span></p>
<p class="msp__step">01 — Circle</p>
</div>
</div>
<main class="msp__page">
<header class="msp__nav"><span class="msp__brand">Forma</span><nav class="msp__links"><a href="#">Typefaces</a><a href="#">Studio</a><a href="#">Journal</a></nav></header>
<div class="msp__hero">
<p class="msp__eyebrow">Type foundry & motion studio</p>
<h1 class="msp__title">Shape is a verb.</h1>
<p class="msp__sub">Five letterforms, one gesture.</p>
<button class="msp__replay" type="button">Replay preloader</button>
</div>
</main>
</div>
```
The five `d` strings above are the exact morph targets (all cubic Béziers in a 200×200 box; point counts differ — MorphSVG's default `shapeIndex: "auto"` handles that).
**CSS requirements:**
- `.msp__loader`: `position: fixed; inset: 0; z-index: 10; background: var(--bg, #0a0a0a); display: grid; place-content: center; justify-items: center; gap: clamp(1.5rem, 4vh, 3rem); pointer-events: none;`
- `.msp__svg`: `width: clamp(140px, 26vw, 220px); height: auto; overflow: visible; will-change: transform;`
- `.msp__shape`: `fill: var(--text, #ededed); filter: drop-shadow(0 0 18px color-mix(in srgb, var(--accent, #d4ff3a) 55%, transparent)) drop-shadow(0 0 calc(18px * 3.1) color-mix(in srgb, var(--accent) 28%, transparent));`
- `.msp__outline`: `fill: none; stroke: var(--accent); stroke-width: 1.1; stroke-linecap: round;` — the parent `<g>` is scaled 1.16 about the centre so the ring sits just outside the fill.
- `.msp__pct`: display serif, `font-weight: 300; font-size: clamp(2.4rem, 6vw, 4.2rem); line-height: 1; letter-spacing: -0.04em; font-variant-numeric: tabular-nums;` — `.msp__pct-sign { font-size: .45em; color: var(--muted); vertical-align: .9em }`.
- `.msp__step`: mono, `11px`, uppercase, `letter-spacing: .18em`, muted, `margin-top: .9rem`.
- `.msp__page`: `min-height: 100vh; display: grid; grid-template-rows: auto 1fr; padding: clamp(1.25rem, 3vw, 2.5rem) 6vw clamp(3rem, 8vw, 6rem);` — `.msp__nav` is a flex row with a `1px solid var(--line)` bottom border; `.msp__title` is `clamp(3rem, 10vw, 10rem)`, `line-height: .9`, `letter-spacing: -0.045em`; `.msp__eyebrow` is mono 11px in `--accent`.
- `.msp__nav, .msp__eyebrow, .msp__title, .msp__sub, .msp__replay` start at `opacity: 0` (JS reveals them).
**Animation spec (exact values):**
Create `const counter = { v: 0 }` and one `tl = gsap.timeline()`. Constants: `STEP = 0.9`, `INTRO = 0.7`, `MORPH_END = INTRO + 4 × STEP` (4.3 at the default 0.9), `EXIT_AT = MORPH_END + 0.25` (4.55 at default), `EXIT_DURATION = 0.9`.
1. Reset block at 0 (so `restart()` is clean): `tl.set('.msp__loader', { autoAlpha: 1 }).set('.msp__svg', { scale: 1, transformOrigin: '50% 50%' }).set('.msp__shape', { morphSVG: '#msp-s1', clearProps: 'filter', scale: 0.6, opacity: 0, transformOrigin: '50% 50%' }).set('.msp__outline', { morphSVG: '#msp-s1', drawSVG: '0% 0%', autoAlpha: 1 }).set('.msp__meta', { opacity: 1, y: 0 })` and a `.call()` that writes "01 — Circle".
2. Intro: `tl.to('.msp__shape', { scale: 1, opacity: 1, duration: 0.7, ease: 'back.out(1.6)' }, 0)`, `tl.to('.msp__outline', { drawSVG: '0% 100%', duration: 0.45, ease: 'power2.out' }, 0.05)`, `tl.to('.msp__outline', { drawSVG: '100% 100%', duration: 0.4, ease: 'power2.in' }, 0.45)`.
3. Counter: `tl.fromTo(counter, { v: 0 }, { v: 100, duration: MORPH_END, ease: 'power1.inOut', snap: { v: 1 }, onUpdate: () => pctNum.textContent = String(counter.v) }, 0)`.
4. Morph steps — for `i = 0..3`, `t = INTRO + i × STEP`, `target = '#msp-s' + (i + 2)`, `trace = min(0.5, STEP × 0.56)` (0.5 at default; shrinks with a short step so the sweep always ends before the next swap):
- fill: `tl.to('.msp__shape', { morphSVG: target, duration: 0.9, ease: 'power2.inOut' }, t)`
- outline swap while invisible: `tl.set('.msp__outline', { morphSVG: target, drawSVG: '0% 0%' }, t)`
- trace in: `tl.to('.msp__outline', { drawSVG: '0% 100%', duration: trace, ease: 'power2.out' }, t)`
- trace out: `tl.to('.msp__outline', { drawSVG: '100% 100%', duration: trace × 0.84, ease: 'power2.in' }, t + trace × 0.96)` (0.42s at +0.48 by default)
- label: `tl.call(() => stepLabel.textContent = '0' + (i + 2) + ' — ' + name, null, t)` with names Square, Blob, Star, Mark.
5. Exit at `EXIT_AT`: `tl.set('.msp__shape', { filter: 'none' }, EXIT_AT)`, `tl.set('.msp__outline', { autoAlpha: 0 }, EXIT_AT)`, `tl.to('.msp__meta', { opacity: 0, y: -12, duration: 0.35, ease: 'power2.in' }, EXIT_AT)`, `tl.to('.msp__svg', { scale: 40, duration: 0.9, ease: 'expo.in' }, EXIT_AT)`.
6. Reveal: `tl.to('.msp__loader', { autoAlpha: 0, duration: 0.6, ease: 'power2.out' }, EXIT_AT + EXIT_DURATION)` and `tl.fromTo(['.msp__nav', '.msp__eyebrow', '.msp__title', '.msp__sub', '.msp__replay'], { y: 28, opacity: 0 }, { y: 0, opacity: 1, duration: 1, ease: 'power3.out', stagger: 0.08 }, EXIT_AT + EXIT_DURATION - 0.05)`.
Total ≈ EXIT_AT + EXIT_DURATION + 1.3 (≈6.7s at defaults).
**Interaction:** Purely time-based; no scroll or cursor dependency. `.msp__replay` click → `tl.restart()`. Nav links lighten on hover (CSS, 0.2s).
**Responsive behavior:** Everything is fluid (`clamp()` sizes, `26vw` SVG); nothing changes structurally under 768px except the third nav link is hidden. The exit scale always covers the viewport as long as `140px × 40` exceeds the longest viewport edge (5600px at the default 40). `gsap.matchMedia()` is not required; if the headline wraps to two lines on phones that is intended.
**Accessibility:** The loader is `aria-hidden="true"` and `pointer-events: none`. Under `prefers-reduced-motion: reduce` do not run the timeline: `display: none` the loader, show the page content at full opacity and hide Replay (do it in CSS and JS). The page content remains in the DOM throughout so screen readers are never blocked by the loader. Replay has a visible focus ring (`outline: 2px solid var(--accent); outline-offset: 3px`).
**Performance rules:** Only `d` (morph), `stroke-dasharray/offset` (draw), `transform` and `opacity` change. Set `filter: none` on the fill *before* the 40× scale — a `drop-shadow` on a multi-thousand-pixel element tanks the frame rate. Never animate `drawSVG` on a path whose `d` is mid-morph (DrawSVG measures length at tween start). Everything is inside `gsap.context()` and reverted on unmount (`ctx.revert()` restores the original `d` attributes too). The host pauses `gsap.globalTimeline` when the demo is off-screen.
**Customization knobs:**
- `STEP` (0.9) — seconds per morph; `INTRO` (0.7).
- The five `d` strings — swap `#msp-s5` for your real logo path (keep it inside the 200×200 box).
- Morph ease `power2.inOut`; add `type: 'rotational'` to `morphSVG` for blobbier transitions.
- Glow radius (18px inner, ×3.1 outer) and colour (`--accent`: #d4ff3a); ring offset via the group's `scale(1.16)`.
- Exit scale (40), exit duration (0.9s) and ease (`expo.in`).
**Acceptance criteria:**
- [ ] The form passes through all five shapes in order, each morph 0.9s `power2.inOut`, with no flicker or path "flip".
- [ ] The accent outline is never visible at a step boundary and always traces the shape the fill is turning into.
- [ ] Percentage reaches exactly 100 as the fifth shape lands (`0.7 + 4 × 0.9`s) and the step label reads "05 — MARK".
- [ ] The glow disappears before the scale-up; the form fully covers the viewport before the loader fades.
- [ ] Page content rises in with a 0.08s stagger; Replay restarts cleanly from the circle at 0%.
- [ ] Reduced-motion users see the page immediately; no console errors. Add this effect to my existing site/section: **Morphing SVG Preloader**. Keep my page markup and styles; add a `position: fixed; inset: 0; z-index: 10` `.msp__loader` with a 200×200 `viewBox` SVG containing a filled `.msp__shape` path (`fill: var(--text)`, a `18px` `drop-shadow` glow in `#d4ff3a`), a `.msp__outline` stroke path inside a group scaled 1.16 about the centre, five hidden target paths in `<defs>` (use my logo as the fifth), and a tabular-nums percentage with a step label. With GSAP MorphSVGPlugin + DrawSVGPlugin build one timeline: intro `scale 0.6→1` with `back.out(1.6)` (0.7s) and one outline sweep; then four steps at `0.7 + i×0.9`: fill `{ morphSVG: '#next', duration: 0.9, ease: 'power2.inOut' }`, outline `.set({ morphSVG: '#next', drawSVG: '0% 0%' })` then `drawSVG '0% 100%'` (`min(0.5, 0.9×0.56)`s `power2.out`) and `'100% 100%'` (×0.84 duration, `power2.in`, starting at ×0.96); counter `{ v: 0→100, duration: 0.7 + 4×0.9, ease: 'power1.inOut', snap: { v: 1 } }`. 0.25s after the last morph set `filter: none`, scale the SVG to 40 over 0.9s `expo.in`, fade the loader (0.6s) and rise my page content (`y 28→0, opacity 0→1, 1s power3.out, stagger 0.08`). Wrap in `gsap.context()`, hide the loader for `prefers-reduced-motion`, and do not change my colours or fonts. ### EFFECT (React / Next.js): Morphing SVG Preloader
Build **Morphing SVG Preloader** as a self-contained React component for a Next.js App Router project (also valid in Vite + React). Follow the specification below exactly; the vanilla reference implementation at the end is the source of truth for values and structure.
**Setup**
- `npm i gsap @gsap/react`
- File: `components/MorphingSVGPreloader.tsx`, first line `"use client";` (GSAP touches the DOM).
- Imports:
```tsx
"use client";
import { useRef } from "react";
import gsap from "gsap";
import { DrawSVGPlugin } from "gsap/DrawSVGPlugin";
import { MorphSVGPlugin } from "gsap/MorphSVGPlugin";
import { useGSAP } from "@gsap/react";
gsap.registerPlugin(useGSAP, DrawSVGPlugin, MorphSVGPlugin);
export default function MorphingSVGPreloader() {
const scope = useRef<HTMLDivElement>(null);
useGSAP(() => {
// 1. query elements inside `scope.current` (never document-wide)
// 2. wait for document.fonts.ready before any SplitText
// 3. build the timeline / ScrollTriggers exactly as in the specification
// 4. return nothing — useGSAP reverts everything automatically on unmount
}, { scope });
return (
<div ref={scope} className="msp">
{/* markup from the HTML structure section, class → className */}
</div>
);
}
```
**React-specific rules**
- Everything GSAP-related lives inside `useGSAP(() => {...}, { scope })`; it handles cleanup (`ctx.revert()`) on unmount and in React Strict Mode double-invocation.
- Use refs or `gsap.utils.selector(scope)` instead of `document.querySelector`.
- Event listeners added inside useGSAP must use `contextSafe` (`const { contextSafe } = useGSAP({ scope })`) so their tweens are reverted too.
- ScrollTrigger: call `ScrollTrigger.refresh()` after images/fonts load; if the page uses Lenis, sync it once at the app root (`lenis.on('scroll', ScrollTrigger.update)`).
- Styles: copy the CSS requirements into a CSS Module (`MorphingSVGPreloader.module.css`) or Tailwind; keep the class names so the spec maps 1:1.
- Respect `prefers-reduced-motion` with `gsap.matchMedia()` as in the spec.
---
### EFFECT: Morphing SVG Preloader
**What it does (visual):** The page opens on a solid dark screen. In the centre an off-white circle (about 26vw wide, max 220px) pops in from 60% scale with a slight overshoot, haloed by a soft glow in `#d4ff3a` (18px inner radius, a wider ~3× outer halo). A 1px `#d4ff3a` line sweeps once around a ring just outside the circle and vanishes. Beneath the form a large light serif percentage reads "0%" with the mono label "01 — CIRCLE" under it. Then, every 0.9 seconds, the filled form melts into the next shape — circle → rounded square → organic blob → soft four-point star → a leaf-like logo mark — each morph easing with `power2.inOut`. At the start of each step the outline (already in the shape of the *next* form) draws itself clockwise around the ring and then draws itself away again, so it always traces the form the fill is becoming. The percentage climbs 0 → 100 across the whole morph sequence (integer steps, tabular digits) and the label updates per step ("02 — SQUARE" … "05 — MARK"). At 100% the glow switches off, the outline disappears, the percentage fades up and away, and the mark swells 40× in 0.9s (slow then violently fast) until it covers the viewport; the loader then fades out over 0.6s revealing a page whose nav bar, accent eyebrow, giant serif headline "Shape is a verb.", paragraph and Replay button rise in with a stagger. Replay runs it all again.
**Tech stack:**
- GSAP 3.13+ with MorphSVGPlugin and DrawSVGPlugin (both free since 3.13).
- CDN: `gsap.min.js`, `MorphSVGPlugin.min.js`, `DrawSVGPlugin.min.js` from `https://cdn.jsdelivr.net/npm/gsap@3/dist/`.
- npm: `import gsap from "gsap"; import { MorphSVGPlugin } from "gsap/MorphSVGPlugin"; import { DrawSVGPlugin } from "gsap/DrawSVGPlugin"; gsap.registerPlugin(MorphSVGPlugin, DrawSVGPlugin);`
- Fonts: a variable serif for the percentage and headline (demo: Fraunces 300), a mono for labels.
**HTML structure:**
```html
<div class="msp">
<div class="msp__loader" aria-hidden="true">
<svg class="msp__svg" viewBox="0 0 200 200" width="200" height="200">
<defs>
<path id="msp-s1" d="M100,20 C144.2,20 180,55.8 180,100 C180,144.2 144.2,180 100,180 C55.8,180 20,144.2 20,100 C20,55.8 55.8,20 100,20 Z"/>
<path id="msp-s2" d="M62,30 H138 C155.7,30 170,44.3 170,62 V138 C170,155.7 155.7,170 138,170 H62 C44.3,170 30,155.7 30,138 V62 C30,44.3 44.3,30 62,30 Z"/>
<path id="msp-s3" d="M104,22 C146,16 182,54 178,98 C174,142 148,184 102,180 C56,176 18,146 22,100 C26,54 62,28 104,22 Z"/>
<path id="msp-s4" d="M100,18 C110,66 134,90 182,100 C134,110 110,134 100,182 C90,134 66,110 18,100 C66,90 90,66 100,18 Z"/>
<path id="msp-s5" d="M38,162 C38,86 92,38 162,38 C162,114 108,162 38,162 Z"/>
</defs>
<g class="msp__ring" transform="translate(100 100) scale(1.16) translate(-100 -100)">
<path class="msp__outline" d="(same d as #msp-s1)"/>
</g>
<path class="msp__shape" d="(same d as #msp-s1)"/>
</svg>
<div class="msp__meta">
<p class="msp__pct"><span class="msp__pct-num">0</span><span class="msp__pct-sign">%</span></p>
<p class="msp__step">01 — Circle</p>
</div>
</div>
<main class="msp__page">
<header class="msp__nav"><span class="msp__brand">Forma</span><nav class="msp__links"><a href="#">Typefaces</a><a href="#">Studio</a><a href="#">Journal</a></nav></header>
<div class="msp__hero">
<p class="msp__eyebrow">Type foundry & motion studio</p>
<h1 class="msp__title">Shape is a verb.</h1>
<p class="msp__sub">Five letterforms, one gesture.</p>
<button class="msp__replay" type="button">Replay preloader</button>
</div>
</main>
</div>
```
The five `d` strings above are the exact morph targets (all cubic Béziers in a 200×200 box; point counts differ — MorphSVG's default `shapeIndex: "auto"` handles that).
**CSS requirements:**
- `.msp__loader`: `position: fixed; inset: 0; z-index: 10; background: var(--bg, #0a0a0a); display: grid; place-content: center; justify-items: center; gap: clamp(1.5rem, 4vh, 3rem); pointer-events: none;`
- `.msp__svg`: `width: clamp(140px, 26vw, 220px); height: auto; overflow: visible; will-change: transform;`
- `.msp__shape`: `fill: var(--text, #ededed); filter: drop-shadow(0 0 18px color-mix(in srgb, var(--accent, #d4ff3a) 55%, transparent)) drop-shadow(0 0 calc(18px * 3.1) color-mix(in srgb, var(--accent) 28%, transparent));`
- `.msp__outline`: `fill: none; stroke: var(--accent); stroke-width: 1.1; stroke-linecap: round;` — the parent `<g>` is scaled 1.16 about the centre so the ring sits just outside the fill.
- `.msp__pct`: display serif, `font-weight: 300; font-size: clamp(2.4rem, 6vw, 4.2rem); line-height: 1; letter-spacing: -0.04em; font-variant-numeric: tabular-nums;` — `.msp__pct-sign { font-size: .45em; color: var(--muted); vertical-align: .9em }`.
- `.msp__step`: mono, `11px`, uppercase, `letter-spacing: .18em`, muted, `margin-top: .9rem`.
- `.msp__page`: `min-height: 100vh; display: grid; grid-template-rows: auto 1fr; padding: clamp(1.25rem, 3vw, 2.5rem) 6vw clamp(3rem, 8vw, 6rem);` — `.msp__nav` is a flex row with a `1px solid var(--line)` bottom border; `.msp__title` is `clamp(3rem, 10vw, 10rem)`, `line-height: .9`, `letter-spacing: -0.045em`; `.msp__eyebrow` is mono 11px in `--accent`.
- `.msp__nav, .msp__eyebrow, .msp__title, .msp__sub, .msp__replay` start at `opacity: 0` (JS reveals them).
**Animation spec (exact values):**
Create `const counter = { v: 0 }` and one `tl = gsap.timeline()`. Constants: `STEP = 0.9`, `INTRO = 0.7`, `MORPH_END = INTRO + 4 × STEP` (4.3 at the default 0.9), `EXIT_AT = MORPH_END + 0.25` (4.55 at default), `EXIT_DURATION = 0.9`.
1. Reset block at 0 (so `restart()` is clean): `tl.set('.msp__loader', { autoAlpha: 1 }).set('.msp__svg', { scale: 1, transformOrigin: '50% 50%' }).set('.msp__shape', { morphSVG: '#msp-s1', clearProps: 'filter', scale: 0.6, opacity: 0, transformOrigin: '50% 50%' }).set('.msp__outline', { morphSVG: '#msp-s1', drawSVG: '0% 0%', autoAlpha: 1 }).set('.msp__meta', { opacity: 1, y: 0 })` and a `.call()` that writes "01 — Circle".
2. Intro: `tl.to('.msp__shape', { scale: 1, opacity: 1, duration: 0.7, ease: 'back.out(1.6)' }, 0)`, `tl.to('.msp__outline', { drawSVG: '0% 100%', duration: 0.45, ease: 'power2.out' }, 0.05)`, `tl.to('.msp__outline', { drawSVG: '100% 100%', duration: 0.4, ease: 'power2.in' }, 0.45)`.
3. Counter: `tl.fromTo(counter, { v: 0 }, { v: 100, duration: MORPH_END, ease: 'power1.inOut', snap: { v: 1 }, onUpdate: () => pctNum.textContent = String(counter.v) }, 0)`.
4. Morph steps — for `i = 0..3`, `t = INTRO + i × STEP`, `target = '#msp-s' + (i + 2)`, `trace = min(0.5, STEP × 0.56)` (0.5 at default; shrinks with a short step so the sweep always ends before the next swap):
- fill: `tl.to('.msp__shape', { morphSVG: target, duration: 0.9, ease: 'power2.inOut' }, t)`
- outline swap while invisible: `tl.set('.msp__outline', { morphSVG: target, drawSVG: '0% 0%' }, t)`
- trace in: `tl.to('.msp__outline', { drawSVG: '0% 100%', duration: trace, ease: 'power2.out' }, t)`
- trace out: `tl.to('.msp__outline', { drawSVG: '100% 100%', duration: trace × 0.84, ease: 'power2.in' }, t + trace × 0.96)` (0.42s at +0.48 by default)
- label: `tl.call(() => stepLabel.textContent = '0' + (i + 2) + ' — ' + name, null, t)` with names Square, Blob, Star, Mark.
5. Exit at `EXIT_AT`: `tl.set('.msp__shape', { filter: 'none' }, EXIT_AT)`, `tl.set('.msp__outline', { autoAlpha: 0 }, EXIT_AT)`, `tl.to('.msp__meta', { opacity: 0, y: -12, duration: 0.35, ease: 'power2.in' }, EXIT_AT)`, `tl.to('.msp__svg', { scale: 40, duration: 0.9, ease: 'expo.in' }, EXIT_AT)`.
6. Reveal: `tl.to('.msp__loader', { autoAlpha: 0, duration: 0.6, ease: 'power2.out' }, EXIT_AT + EXIT_DURATION)` and `tl.fromTo(['.msp__nav', '.msp__eyebrow', '.msp__title', '.msp__sub', '.msp__replay'], { y: 28, opacity: 0 }, { y: 0, opacity: 1, duration: 1, ease: 'power3.out', stagger: 0.08 }, EXIT_AT + EXIT_DURATION - 0.05)`.
Total ≈ EXIT_AT + EXIT_DURATION + 1.3 (≈6.7s at defaults).
**Interaction:** Purely time-based; no scroll or cursor dependency. `.msp__replay` click → `tl.restart()`. Nav links lighten on hover (CSS, 0.2s).
**Responsive behavior:** Everything is fluid (`clamp()` sizes, `26vw` SVG); nothing changes structurally under 768px except the third nav link is hidden. The exit scale always covers the viewport as long as `140px × 40` exceeds the longest viewport edge (5600px at the default 40). `gsap.matchMedia()` is not required; if the headline wraps to two lines on phones that is intended.
**Accessibility:** The loader is `aria-hidden="true"` and `pointer-events: none`. Under `prefers-reduced-motion: reduce` do not run the timeline: `display: none` the loader, show the page content at full opacity and hide Replay (do it in CSS and JS). The page content remains in the DOM throughout so screen readers are never blocked by the loader. Replay has a visible focus ring (`outline: 2px solid var(--accent); outline-offset: 3px`).
**Performance rules:** Only `d` (morph), `stroke-dasharray/offset` (draw), `transform` and `opacity` change. Set `filter: none` on the fill *before* the 40× scale — a `drop-shadow` on a multi-thousand-pixel element tanks the frame rate. Never animate `drawSVG` on a path whose `d` is mid-morph (DrawSVG measures length at tween start). Everything is inside `gsap.context()` and reverted on unmount (`ctx.revert()` restores the original `d` attributes too). The host pauses `gsap.globalTimeline` when the demo is off-screen.
**Customization knobs:**
- `STEP` (0.9) — seconds per morph; `INTRO` (0.7).
- The five `d` strings — swap `#msp-s5` for your real logo path (keep it inside the 200×200 box).
- Morph ease `power2.inOut`; add `type: 'rotational'` to `morphSVG` for blobbier transitions.
- Glow radius (18px inner, ×3.1 outer) and colour (`--accent`: #d4ff3a); ring offset via the group's `scale(1.16)`.
- Exit scale (40), exit duration (0.9s) and ease (`expo.in`).
**Acceptance criteria:**
- [ ] The form passes through all five shapes in order, each morph 0.9s `power2.inOut`, with no flicker or path "flip".
- [ ] The accent outline is never visible at a step boundary and always traces the shape the fill is turning into.
- [ ] Percentage reaches exactly 100 as the fifth shape lands (`0.7 + 4 × 0.9`s) and the step label reads "05 — MARK".
- [ ] The glow disappears before the scale-up; the form fully covers the viewport before the loader fades.
- [ ] Page content rises in with a 0.08s stagger; Replay restarts cleanly from the circle at 0%.
- [ ] Reduced-motion users see the page immediately; no console errors.
---
**Reference implementation (vanilla, converts 1:1 — same class names, same values):**
```html
<div class="msp">
<div class="msp__loader" aria-hidden="true">
<svg class="msp__svg" viewBox="0 0 200 200" width="200" height="200">
<defs>
<path id="msp-s1" d="M100,20 C144.2,20 180,55.8 180,100 C180,144.2 144.2,180 100,180 C55.8,180 20,144.2 20,100 C20,55.8 55.8,20 100,20 Z"/>
<path id="msp-s2" d="M62,30 H138 C155.7,30 170,44.3 170,62 V138 C170,155.7 155.7,170 138,170 H62 C44.3,170 30,155.7 30,138 V62 C30,44.3 44.3,30 62,30 Z"/>
<path id="msp-s3" d="M104,22 C146,16 182,54 178,98 C174,142 148,184 102,180 C56,176 18,146 22,100 C26,54 62,28 104,22 Z"/>
<path id="msp-s4" d="M100,18 C110,66 134,90 182,100 C134,110 110,134 100,182 C90,134 66,110 18,100 C66,90 90,66 100,18 Z"/>
<path id="msp-s5" d="M38,162 C38,86 92,38 162,38 C162,114 108,162 38,162 Z"/>
</defs>
<g class="msp__ring" transform="translate(100 100) scale(1.16) translate(-100 -100)">
<path class="msp__outline" d="M100,20 C144.2,20 180,55.8 180,100 C180,144.2 144.2,180 100,180 C55.8,180 20,144.2 20,100 C20,55.8 55.8,20 100,20 Z"/>
</g>
<path class="msp__shape" d="M100,20 C144.2,20 180,55.8 180,100 C180,144.2 144.2,180 100,180 C55.8,180 20,144.2 20,100 C20,55.8 55.8,20 100,20 Z"/>
</svg>
<div class="msp__meta">
<p class="msp__pct"><span class="msp__pct-num">0</span><span class="msp__pct-sign">%</span></p>
<p class="msp__step">01 — Circle</p>
</div>
</div>
<main class="msp__page">
<header class="msp__nav">
<span class="msp__brand">Forma</span>
<nav class="msp__links" aria-label="Site"><a href="#">Typefaces</a><a href="#">Studio</a><a href="#">Journal</a></nav>
</header>
<div class="msp__hero">
<p class="msp__eyebrow">Type foundry & motion studio</p>
<h1 class="msp__title">Shape is a verb.</h1>
<p class="msp__sub">Five letterforms, one gesture. Forma draws type that was designed to move — every glyph has a morph target.</p>
<button class="msp__replay" type="button">Replay preloader</button>
</div>
</main>
</div>
```
```css
.msp { --msp-accent: var(--knob-accent, var(--accent)); --msp-glow: var(--knob-glow, 18px); position: relative; min-height: 100vh; }
/* ---------- loader ---------- */
.msp__loader { position: fixed; inset: 0; z-index: 10; background: var(--bg); display: grid; place-content: center; justify-items: center; gap: clamp(1.5rem, 4vh, 3rem); pointer-events: none; }
.msp__svg { width: clamp(140px, 26vw, 220px); height: auto; overflow: visible; will-change: transform; }
.msp__shape {
fill: var(--text);
filter: drop-shadow(0 0 var(--msp-glow) color-mix(in srgb, var(--msp-accent) 55%, transparent)) drop-shadow(0 0 calc(var(--msp-glow) * 3.1) color-mix(in srgb, var(--msp-accent) 28%, transparent));
}
.msp__outline { fill: none; stroke: var(--msp-accent); stroke-width: 1.1; stroke-linecap: round; }
.msp__meta { text-align: center; }
.msp__pct { font-family: var(--font-display); font-weight: 300; font-variation-settings: 'opsz' 144; font-size: clamp(2.4rem, 6vw, 4.2rem); line-height: 1; letter-spacing: -0.04em; font-variant-numeric: tabular-nums; color: var(--text); }
.msp__pct-sign { font-size: .45em; color: var(--muted); margin-left: .1em; vertical-align: .9em; }
.msp__step { margin-top: .9rem; font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: .18em; color: var(--muted); }
/* ---------- page ---------- */
.msp__page { min-height: 100vh; display: grid; grid-template-rows: auto 1fr; padding: clamp(1.25rem, 3vw, 2.5rem) 6vw clamp(3rem, 8vw, 6rem); color: var(--text); }
.msp__nav { display: flex; justify-content: space-between; align-items: baseline; padding-bottom: 1.25rem; border-bottom: 1px solid var(--line); opacity: 0; }
.msp__brand { font-family: var(--font-display); font-weight: 400; font-size: 1.35rem; letter-spacing: -0.03em; }
.msp__links { display: flex; gap: clamp(1rem, 2.5vw, 2.25rem); font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: .12em; color: var(--muted); }
.msp__links a { text-decoration: none; }
.msp__links a:hover { color: var(--text); }
.msp__hero { align-self: center; max-width: 1100px; padding-top: clamp(2rem, 6vh, 4rem); }
.msp__eyebrow { font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: .14em; color: var(--msp-accent); opacity: 0; }
.msp__title { font-family: var(--font-display); font-weight: 300; font-variation-settings: 'opsz' 144, 'SOFT' 40; font-size: clamp(3rem, 10vw, 10rem); line-height: .9; letter-spacing: -0.045em; margin: .4em 0 .35em; opacity: 0; }
.msp__sub { max-width: 46ch; color: var(--muted); font-size: clamp(1rem, 1.3vw, 1.2rem); line-height: 1.5; opacity: 0; }
.msp__replay {
margin-top: 2.5rem; padding: .7em 1.4em; border: 1px solid color-mix(in srgb, var(--text) 25%, transparent); border-radius: 999px;
font-size: .85rem; font-weight: 500; opacity: 0; transition: background .25s, color .25s, border-color .25s;
}
.msp__replay:hover { background: var(--text); color: var(--bg); border-color: var(--text); }
.msp__replay:focus-visible { outline: 2px solid var(--msp-accent); outline-offset: 3px; }
@media (max-width: 767px) {
.msp__links a:nth-child(3) { display: none; }
}
@media (prefers-reduced-motion: reduce) {
.msp__loader { display: none; }
.msp__nav, .msp__eyebrow, .msp__title, .msp__sub { opacity: 1; }
.msp__replay { display: none; }
}
```
```js
// Morphing SVG Preloader — one glowing form morphs through five shapes, then swells to fill the screen.
gsap.registerPlugin(MorphSVGPlugin, DrawSVGPlugin);
// Defaults — overridable from the MOTION/LAB Playground via MotionLab.knob()
const DEFAULTS = { step: 0.9, morphEase: 'power2.inOut', exitScale: 40, exitDuration: 0.9, glowSize: 18, accent: '#d4ff3a' };
const K = (id) => window.MotionLab?.knob(id, DEFAULTS[id]) ?? DEFAULTS[id];
const SHAPES = ['Circle', 'Square', 'Blob', 'Star', 'Mark'];
const INTRO = 0.7; // circle pops in + first outline sweep
const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
const root = document.querySelector('.msp');
const loader = root.querySelector('.msp__loader');
const svg = root.querySelector('.msp__svg');
const shape = root.querySelector('.msp__shape');
const outline = root.querySelector('.msp__outline');
const meta = root.querySelector('.msp__meta');
const pct = root.querySelector('.msp__pct-num');
const stepLabel = root.querySelector('.msp__step');
const pageItems = [root.querySelector('.msp__nav'), root.querySelector('.msp__eyebrow'), root.querySelector('.msp__title'), root.querySelector('.msp__sub'), root.querySelector('.msp__replay')];
const replay = root.querySelector('.msp__replay');
let ctx, ac;
function build() {
ctx?.revert();
ac?.abort();
ac = new AbortController();
root.style.setProperty('--knob-accent', K('accent'));
root.style.setProperty('--knob-glow', K('glowSize') + 'px');
const STEP = K('step'); // seconds per morph
const MORPH_END = INTRO + STEP * (SHAPES.length - 1); // 4.3s at defaults
const EXIT_AT = MORPH_END + 0.25; // 4.55s at defaults
const EXIT_DURATION = K('exitDuration');
const EXIT_SCALE = K('exitScale');
const morphEase = K('morphEase');
ctx = gsap.context(() => {
if (reduced) {
gsap.set(loader, { display: 'none' });
gsap.set(pageItems, { autoAlpha: 1 });
return;
}
const counter = { v: 0 };
const tl = gsap.timeline();
// Reset (makes restart() clean)
tl.set(loader, { autoAlpha: 1 })
.set(svg, { scale: 1, transformOrigin: '50% 50%' })
.set(shape, { morphSVG: '#msp-s1', clearProps: 'filter', scale: 0.6, opacity: 0, transformOrigin: '50% 50%' })
.set(outline, { morphSVG: '#msp-s1', drawSVG: '0% 0%', autoAlpha: 1 })
.set(meta, { opacity: 1, y: 0 })
.call(() => { stepLabel.textContent = '01 — Circle'; });
// Intro: the circle pops in while its outline sweeps once
tl.to(shape, { scale: 1, opacity: 1, duration: 0.7, ease: 'back.out(1.6)' }, 0)
.to(outline, { drawSVG: '0% 100%', duration: 0.45, ease: 'power2.out' }, 0.05)
.to(outline, { drawSVG: '100% 100%', duration: 0.4, ease: 'power2.in' }, 0.45);
// Percentage runs across the whole morph sequence
tl.fromTo(counter, { v: 0 }, {
v: 100, duration: MORPH_END, ease: 'power1.inOut', snap: { v: 1 },
onUpdate: () => { pct.textContent = String(counter.v); },
}, 0);
// Morph steps: fill morphs, outline is swapped to the target shape while invisible then traces it
const trace = Math.min(0.5, STEP * 0.56); // 0.5s at the default step; shrinks so the sweep ends before the next swap
SHAPES.slice(1).forEach((name, i) => {
const t = INTRO + i * STEP;
const target = `#msp-s${i + 2}`;
tl.to(shape, { morphSVG: target, duration: STEP, ease: morphEase }, t)
.set(outline, { morphSVG: target, drawSVG: '0% 0%' }, t)
.to(outline, { drawSVG: '0% 100%', duration: trace, ease: 'power2.out' }, t)
.to(outline, { drawSVG: '100% 100%', duration: trace * 0.84, ease: 'power2.in' }, t + trace * 0.96)
.call(() => { stepLabel.textContent = `0${i + 2} — ${name}`; }, null, t);
});
// Exit: kill the glow, swell to fill the viewport, then fade the loader as the page rises
tl.set(shape, { filter: 'none' }, EXIT_AT)
.set(outline, { autoAlpha: 0 }, EXIT_AT)
.to(meta, { opacity: 0, y: -12, duration: 0.35, ease: 'power2.in' }, EXIT_AT)
.to(svg, { scale: EXIT_SCALE, duration: EXIT_DURATION, ease: 'expo.in' }, EXIT_AT)
.to(loader, { autoAlpha: 0, duration: 0.6, ease: 'power2.out' }, EXIT_AT + EXIT_DURATION)
.fromTo(pageItems, { y: 28, opacity: 0 }, { y: 0, opacity: 1, duration: 1, ease: 'power3.out', stagger: 0.08 }, EXIT_AT + EXIT_DURATION - 0.05);
replay.addEventListener('click', () => tl.restart(), { signal: ac.signal });
});
}
build();
// Playground: rebuild with the new values
window.addEventListener('ml:knobs', build);
window.MotionLab?.onCleanup(() => { ctx?.revert(); ac?.abort(); });
```
### EFFECT (single HTML file): Morphing SVG Preloader
Produce **one self-contained `index.html`** (inline `<style>` and `<script type="module">`, no build step) that implements **Morphing SVG Preloader** exactly as specified below. Libraries load from jsDelivr:
```html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Morphing SVG Preloader</title>
<link href="https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,300..600;1,9..144,300..600&family=Inter+Tight:wght@300..700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
:root{--bg:#0a0a0a;--surface:#121212;--line:rgba(255,255,255,.08);--text:#ededed;--muted:#8a8a8a;--accent:#d4ff3a;--font-display:"Fraunces",Georgia,serif;--font-sans:"Inter Tight",system-ui,sans-serif;--font-mono:"JetBrains Mono",ui-monospace,monospace}
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
body{background:var(--bg);color:var(--text);font-family:var(--font-sans);-webkit-font-smoothing:antialiased;overflow-x:hidden}
/* + the CSS requirements below */
</style>
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/DrawSVGPlugin.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/MorphSVGPlugin.min.js"></script>
</head>
<body>
<!-- markup from the HTML structure section -->
<script type="module">
// the JS from the animation spec (window.MotionLab is optional — keep the ?. guards or remove them)
</script>
</body>
</html>
```
The file must open directly from a local server (`npx serve .`) with zero console errors, work at 375px and 1440px, and honour `prefers-reduced-motion`.
---
### EFFECT: Morphing SVG Preloader
**What it does (visual):** The page opens on a solid dark screen. In the centre an off-white circle (about 26vw wide, max 220px) pops in from 60% scale with a slight overshoot, haloed by a soft glow in `#d4ff3a` (18px inner radius, a wider ~3× outer halo). A 1px `#d4ff3a` line sweeps once around a ring just outside the circle and vanishes. Beneath the form a large light serif percentage reads "0%" with the mono label "01 — CIRCLE" under it. Then, every 0.9 seconds, the filled form melts into the next shape — circle → rounded square → organic blob → soft four-point star → a leaf-like logo mark — each morph easing with `power2.inOut`. At the start of each step the outline (already in the shape of the *next* form) draws itself clockwise around the ring and then draws itself away again, so it always traces the form the fill is becoming. The percentage climbs 0 → 100 across the whole morph sequence (integer steps, tabular digits) and the label updates per step ("02 — SQUARE" … "05 — MARK"). At 100% the glow switches off, the outline disappears, the percentage fades up and away, and the mark swells 40× in 0.9s (slow then violently fast) until it covers the viewport; the loader then fades out over 0.6s revealing a page whose nav bar, accent eyebrow, giant serif headline "Shape is a verb.", paragraph and Replay button rise in with a stagger. Replay runs it all again.
**Tech stack:**
- GSAP 3.13+ with MorphSVGPlugin and DrawSVGPlugin (both free since 3.13).
- CDN: `gsap.min.js`, `MorphSVGPlugin.min.js`, `DrawSVGPlugin.min.js` from `https://cdn.jsdelivr.net/npm/gsap@3/dist/`.
- npm: `import gsap from "gsap"; import { MorphSVGPlugin } from "gsap/MorphSVGPlugin"; import { DrawSVGPlugin } from "gsap/DrawSVGPlugin"; gsap.registerPlugin(MorphSVGPlugin, DrawSVGPlugin);`
- Fonts: a variable serif for the percentage and headline (demo: Fraunces 300), a mono for labels.
**HTML structure:**
```html
<div class="msp">
<div class="msp__loader" aria-hidden="true">
<svg class="msp__svg" viewBox="0 0 200 200" width="200" height="200">
<defs>
<path id="msp-s1" d="M100,20 C144.2,20 180,55.8 180,100 C180,144.2 144.2,180 100,180 C55.8,180 20,144.2 20,100 C20,55.8 55.8,20 100,20 Z"/>
<path id="msp-s2" d="M62,30 H138 C155.7,30 170,44.3 170,62 V138 C170,155.7 155.7,170 138,170 H62 C44.3,170 30,155.7 30,138 V62 C30,44.3 44.3,30 62,30 Z"/>
<path id="msp-s3" d="M104,22 C146,16 182,54 178,98 C174,142 148,184 102,180 C56,176 18,146 22,100 C26,54 62,28 104,22 Z"/>
<path id="msp-s4" d="M100,18 C110,66 134,90 182,100 C134,110 110,134 100,182 C90,134 66,110 18,100 C66,90 90,66 100,18 Z"/>
<path id="msp-s5" d="M38,162 C38,86 92,38 162,38 C162,114 108,162 38,162 Z"/>
</defs>
<g class="msp__ring" transform="translate(100 100) scale(1.16) translate(-100 -100)">
<path class="msp__outline" d="(same d as #msp-s1)"/>
</g>
<path class="msp__shape" d="(same d as #msp-s1)"/>
</svg>
<div class="msp__meta">
<p class="msp__pct"><span class="msp__pct-num">0</span><span class="msp__pct-sign">%</span></p>
<p class="msp__step">01 — Circle</p>
</div>
</div>
<main class="msp__page">
<header class="msp__nav"><span class="msp__brand">Forma</span><nav class="msp__links"><a href="#">Typefaces</a><a href="#">Studio</a><a href="#">Journal</a></nav></header>
<div class="msp__hero">
<p class="msp__eyebrow">Type foundry & motion studio</p>
<h1 class="msp__title">Shape is a verb.</h1>
<p class="msp__sub">Five letterforms, one gesture.</p>
<button class="msp__replay" type="button">Replay preloader</button>
</div>
</main>
</div>
```
The five `d` strings above are the exact morph targets (all cubic Béziers in a 200×200 box; point counts differ — MorphSVG's default `shapeIndex: "auto"` handles that).
**CSS requirements:**
- `.msp__loader`: `position: fixed; inset: 0; z-index: 10; background: var(--bg, #0a0a0a); display: grid; place-content: center; justify-items: center; gap: clamp(1.5rem, 4vh, 3rem); pointer-events: none;`
- `.msp__svg`: `width: clamp(140px, 26vw, 220px); height: auto; overflow: visible; will-change: transform;`
- `.msp__shape`: `fill: var(--text, #ededed); filter: drop-shadow(0 0 18px color-mix(in srgb, var(--accent, #d4ff3a) 55%, transparent)) drop-shadow(0 0 calc(18px * 3.1) color-mix(in srgb, var(--accent) 28%, transparent));`
- `.msp__outline`: `fill: none; stroke: var(--accent); stroke-width: 1.1; stroke-linecap: round;` — the parent `<g>` is scaled 1.16 about the centre so the ring sits just outside the fill.
- `.msp__pct`: display serif, `font-weight: 300; font-size: clamp(2.4rem, 6vw, 4.2rem); line-height: 1; letter-spacing: -0.04em; font-variant-numeric: tabular-nums;` — `.msp__pct-sign { font-size: .45em; color: var(--muted); vertical-align: .9em }`.
- `.msp__step`: mono, `11px`, uppercase, `letter-spacing: .18em`, muted, `margin-top: .9rem`.
- `.msp__page`: `min-height: 100vh; display: grid; grid-template-rows: auto 1fr; padding: clamp(1.25rem, 3vw, 2.5rem) 6vw clamp(3rem, 8vw, 6rem);` — `.msp__nav` is a flex row with a `1px solid var(--line)` bottom border; `.msp__title` is `clamp(3rem, 10vw, 10rem)`, `line-height: .9`, `letter-spacing: -0.045em`; `.msp__eyebrow` is mono 11px in `--accent`.
- `.msp__nav, .msp__eyebrow, .msp__title, .msp__sub, .msp__replay` start at `opacity: 0` (JS reveals them).
**Animation spec (exact values):**
Create `const counter = { v: 0 }` and one `tl = gsap.timeline()`. Constants: `STEP = 0.9`, `INTRO = 0.7`, `MORPH_END = INTRO + 4 × STEP` (4.3 at the default 0.9), `EXIT_AT = MORPH_END + 0.25` (4.55 at default), `EXIT_DURATION = 0.9`.
1. Reset block at 0 (so `restart()` is clean): `tl.set('.msp__loader', { autoAlpha: 1 }).set('.msp__svg', { scale: 1, transformOrigin: '50% 50%' }).set('.msp__shape', { morphSVG: '#msp-s1', clearProps: 'filter', scale: 0.6, opacity: 0, transformOrigin: '50% 50%' }).set('.msp__outline', { morphSVG: '#msp-s1', drawSVG: '0% 0%', autoAlpha: 1 }).set('.msp__meta', { opacity: 1, y: 0 })` and a `.call()` that writes "01 — Circle".
2. Intro: `tl.to('.msp__shape', { scale: 1, opacity: 1, duration: 0.7, ease: 'back.out(1.6)' }, 0)`, `tl.to('.msp__outline', { drawSVG: '0% 100%', duration: 0.45, ease: 'power2.out' }, 0.05)`, `tl.to('.msp__outline', { drawSVG: '100% 100%', duration: 0.4, ease: 'power2.in' }, 0.45)`.
3. Counter: `tl.fromTo(counter, { v: 0 }, { v: 100, duration: MORPH_END, ease: 'power1.inOut', snap: { v: 1 }, onUpdate: () => pctNum.textContent = String(counter.v) }, 0)`.
4. Morph steps — for `i = 0..3`, `t = INTRO + i × STEP`, `target = '#msp-s' + (i + 2)`, `trace = min(0.5, STEP × 0.56)` (0.5 at default; shrinks with a short step so the sweep always ends before the next swap):
- fill: `tl.to('.msp__shape', { morphSVG: target, duration: 0.9, ease: 'power2.inOut' }, t)`
- outline swap while invisible: `tl.set('.msp__outline', { morphSVG: target, drawSVG: '0% 0%' }, t)`
- trace in: `tl.to('.msp__outline', { drawSVG: '0% 100%', duration: trace, ease: 'power2.out' }, t)`
- trace out: `tl.to('.msp__outline', { drawSVG: '100% 100%', duration: trace × 0.84, ease: 'power2.in' }, t + trace × 0.96)` (0.42s at +0.48 by default)
- label: `tl.call(() => stepLabel.textContent = '0' + (i + 2) + ' — ' + name, null, t)` with names Square, Blob, Star, Mark.
5. Exit at `EXIT_AT`: `tl.set('.msp__shape', { filter: 'none' }, EXIT_AT)`, `tl.set('.msp__outline', { autoAlpha: 0 }, EXIT_AT)`, `tl.to('.msp__meta', { opacity: 0, y: -12, duration: 0.35, ease: 'power2.in' }, EXIT_AT)`, `tl.to('.msp__svg', { scale: 40, duration: 0.9, ease: 'expo.in' }, EXIT_AT)`.
6. Reveal: `tl.to('.msp__loader', { autoAlpha: 0, duration: 0.6, ease: 'power2.out' }, EXIT_AT + EXIT_DURATION)` and `tl.fromTo(['.msp__nav', '.msp__eyebrow', '.msp__title', '.msp__sub', '.msp__replay'], { y: 28, opacity: 0 }, { y: 0, opacity: 1, duration: 1, ease: 'power3.out', stagger: 0.08 }, EXIT_AT + EXIT_DURATION - 0.05)`.
Total ≈ EXIT_AT + EXIT_DURATION + 1.3 (≈6.7s at defaults).
**Interaction:** Purely time-based; no scroll or cursor dependency. `.msp__replay` click → `tl.restart()`. Nav links lighten on hover (CSS, 0.2s).
**Responsive behavior:** Everything is fluid (`clamp()` sizes, `26vw` SVG); nothing changes structurally under 768px except the third nav link is hidden. The exit scale always covers the viewport as long as `140px × 40` exceeds the longest viewport edge (5600px at the default 40). `gsap.matchMedia()` is not required; if the headline wraps to two lines on phones that is intended.
**Accessibility:** The loader is `aria-hidden="true"` and `pointer-events: none`. Under `prefers-reduced-motion: reduce` do not run the timeline: `display: none` the loader, show the page content at full opacity and hide Replay (do it in CSS and JS). The page content remains in the DOM throughout so screen readers are never blocked by the loader. Replay has a visible focus ring (`outline: 2px solid var(--accent); outline-offset: 3px`).
**Performance rules:** Only `d` (morph), `stroke-dasharray/offset` (draw), `transform` and `opacity` change. Set `filter: none` on the fill *before* the 40× scale — a `drop-shadow` on a multi-thousand-pixel element tanks the frame rate. Never animate `drawSVG` on a path whose `d` is mid-morph (DrawSVG measures length at tween start). Everything is inside `gsap.context()` and reverted on unmount (`ctx.revert()` restores the original `d` attributes too). The host pauses `gsap.globalTimeline` when the demo is off-screen.
**Customization knobs:**
- `STEP` (0.9) — seconds per morph; `INTRO` (0.7).
- The five `d` strings — swap `#msp-s5` for your real logo path (keep it inside the 200×200 box).
- Morph ease `power2.inOut`; add `type: 'rotational'` to `morphSVG` for blobbier transitions.
- Glow radius (18px inner, ×3.1 outer) and colour (`--accent`: #d4ff3a); ring offset via the group's `scale(1.16)`.
- Exit scale (40), exit duration (0.9s) and ease (`expo.in`).
**Acceptance criteria:**
- [ ] The form passes through all five shapes in order, each morph 0.9s `power2.inOut`, with no flicker or path "flip".
- [ ] The accent outline is never visible at a step boundary and always traces the shape the fill is turning into.
- [ ] Percentage reaches exactly 100 as the fifth shape lands (`0.7 + 4 × 0.9`s) and the step label reads "05 — MARK".
- [ ] The glow disappears before the scale-up; the form fully covers the viewport before the loader fades.
- [ ] Page content rises in with a 0.08s stagger; Replay restarts cleanly from the circle at 0%.
- [ ] Reduced-motion users see the page immediately; no console errors.
---
**Reference implementation (use these exact values and class names):**
```html
<div class="msp">
<div class="msp__loader" aria-hidden="true">
<svg class="msp__svg" viewBox="0 0 200 200" width="200" height="200">
<defs>
<path id="msp-s1" d="M100,20 C144.2,20 180,55.8 180,100 C180,144.2 144.2,180 100,180 C55.8,180 20,144.2 20,100 C20,55.8 55.8,20 100,20 Z"/>
<path id="msp-s2" d="M62,30 H138 C155.7,30 170,44.3 170,62 V138 C170,155.7 155.7,170 138,170 H62 C44.3,170 30,155.7 30,138 V62 C30,44.3 44.3,30 62,30 Z"/>
<path id="msp-s3" d="M104,22 C146,16 182,54 178,98 C174,142 148,184 102,180 C56,176 18,146 22,100 C26,54 62,28 104,22 Z"/>
<path id="msp-s4" d="M100,18 C110,66 134,90 182,100 C134,110 110,134 100,182 C90,134 66,110 18,100 C66,90 90,66 100,18 Z"/>
<path id="msp-s5" d="M38,162 C38,86 92,38 162,38 C162,114 108,162 38,162 Z"/>
</defs>
<g class="msp__ring" transform="translate(100 100) scale(1.16) translate(-100 -100)">
<path class="msp__outline" d="M100,20 C144.2,20 180,55.8 180,100 C180,144.2 144.2,180 100,180 C55.8,180 20,144.2 20,100 C20,55.8 55.8,20 100,20 Z"/>
</g>
<path class="msp__shape" d="M100,20 C144.2,20 180,55.8 180,100 C180,144.2 144.2,180 100,180 C55.8,180 20,144.2 20,100 C20,55.8 55.8,20 100,20 Z"/>
</svg>
<div class="msp__meta">
<p class="msp__pct"><span class="msp__pct-num">0</span><span class="msp__pct-sign">%</span></p>
<p class="msp__step">01 — Circle</p>
</div>
</div>
<main class="msp__page">
<header class="msp__nav">
<span class="msp__brand">Forma</span>
<nav class="msp__links" aria-label="Site"><a href="#">Typefaces</a><a href="#">Studio</a><a href="#">Journal</a></nav>
</header>
<div class="msp__hero">
<p class="msp__eyebrow">Type foundry & motion studio</p>
<h1 class="msp__title">Shape is a verb.</h1>
<p class="msp__sub">Five letterforms, one gesture. Forma draws type that was designed to move — every glyph has a morph target.</p>
<button class="msp__replay" type="button">Replay preloader</button>
</div>
</main>
</div>
```
```css
.msp { --msp-accent: var(--knob-accent, var(--accent)); --msp-glow: var(--knob-glow, 18px); position: relative; min-height: 100vh; }
/* ---------- loader ---------- */
.msp__loader { position: fixed; inset: 0; z-index: 10; background: var(--bg); display: grid; place-content: center; justify-items: center; gap: clamp(1.5rem, 4vh, 3rem); pointer-events: none; }
.msp__svg { width: clamp(140px, 26vw, 220px); height: auto; overflow: visible; will-change: transform; }
.msp__shape {
fill: var(--text);
filter: drop-shadow(0 0 var(--msp-glow) color-mix(in srgb, var(--msp-accent) 55%, transparent)) drop-shadow(0 0 calc(var(--msp-glow) * 3.1) color-mix(in srgb, var(--msp-accent) 28%, transparent));
}
.msp__outline { fill: none; stroke: var(--msp-accent); stroke-width: 1.1; stroke-linecap: round; }
.msp__meta { text-align: center; }
.msp__pct { font-family: var(--font-display); font-weight: 300; font-variation-settings: 'opsz' 144; font-size: clamp(2.4rem, 6vw, 4.2rem); line-height: 1; letter-spacing: -0.04em; font-variant-numeric: tabular-nums; color: var(--text); }
.msp__pct-sign { font-size: .45em; color: var(--muted); margin-left: .1em; vertical-align: .9em; }
.msp__step { margin-top: .9rem; font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: .18em; color: var(--muted); }
/* ---------- page ---------- */
.msp__page { min-height: 100vh; display: grid; grid-template-rows: auto 1fr; padding: clamp(1.25rem, 3vw, 2.5rem) 6vw clamp(3rem, 8vw, 6rem); color: var(--text); }
.msp__nav { display: flex; justify-content: space-between; align-items: baseline; padding-bottom: 1.25rem; border-bottom: 1px solid var(--line); opacity: 0; }
.msp__brand { font-family: var(--font-display); font-weight: 400; font-size: 1.35rem; letter-spacing: -0.03em; }
.msp__links { display: flex; gap: clamp(1rem, 2.5vw, 2.25rem); font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: .12em; color: var(--muted); }
.msp__links a { text-decoration: none; }
.msp__links a:hover { color: var(--text); }
.msp__hero { align-self: center; max-width: 1100px; padding-top: clamp(2rem, 6vh, 4rem); }
.msp__eyebrow { font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: .14em; color: var(--msp-accent); opacity: 0; }
.msp__title { font-family: var(--font-display); font-weight: 300; font-variation-settings: 'opsz' 144, 'SOFT' 40; font-size: clamp(3rem, 10vw, 10rem); line-height: .9; letter-spacing: -0.045em; margin: .4em 0 .35em; opacity: 0; }
.msp__sub { max-width: 46ch; color: var(--muted); font-size: clamp(1rem, 1.3vw, 1.2rem); line-height: 1.5; opacity: 0; }
.msp__replay {
margin-top: 2.5rem; padding: .7em 1.4em; border: 1px solid color-mix(in srgb, var(--text) 25%, transparent); border-radius: 999px;
font-size: .85rem; font-weight: 500; opacity: 0; transition: background .25s, color .25s, border-color .25s;
}
.msp__replay:hover { background: var(--text); color: var(--bg); border-color: var(--text); }
.msp__replay:focus-visible { outline: 2px solid var(--msp-accent); outline-offset: 3px; }
@media (max-width: 767px) {
.msp__links a:nth-child(3) { display: none; }
}
@media (prefers-reduced-motion: reduce) {
.msp__loader { display: none; }
.msp__nav, .msp__eyebrow, .msp__title, .msp__sub { opacity: 1; }
.msp__replay { display: none; }
}
```
```js
// Morphing SVG Preloader — one glowing form morphs through five shapes, then swells to fill the screen.
gsap.registerPlugin(MorphSVGPlugin, DrawSVGPlugin);
// Defaults — overridable from the MOTION/LAB Playground via MotionLab.knob()
const DEFAULTS = { step: 0.9, morphEase: 'power2.inOut', exitScale: 40, exitDuration: 0.9, glowSize: 18, accent: '#d4ff3a' };
const K = (id) => window.MotionLab?.knob(id, DEFAULTS[id]) ?? DEFAULTS[id];
const SHAPES = ['Circle', 'Square', 'Blob', 'Star', 'Mark'];
const INTRO = 0.7; // circle pops in + first outline sweep
const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
const root = document.querySelector('.msp');
const loader = root.querySelector('.msp__loader');
const svg = root.querySelector('.msp__svg');
const shape = root.querySelector('.msp__shape');
const outline = root.querySelector('.msp__outline');
const meta = root.querySelector('.msp__meta');
const pct = root.querySelector('.msp__pct-num');
const stepLabel = root.querySelector('.msp__step');
const pageItems = [root.querySelector('.msp__nav'), root.querySelector('.msp__eyebrow'), root.querySelector('.msp__title'), root.querySelector('.msp__sub'), root.querySelector('.msp__replay')];
const replay = root.querySelector('.msp__replay');
let ctx, ac;
function build() {
ctx?.revert();
ac?.abort();
ac = new AbortController();
root.style.setProperty('--knob-accent', K('accent'));
root.style.setProperty('--knob-glow', K('glowSize') + 'px');
const STEP = K('step'); // seconds per morph
const MORPH_END = INTRO + STEP * (SHAPES.length - 1); // 4.3s at defaults
const EXIT_AT = MORPH_END + 0.25; // 4.55s at defaults
const EXIT_DURATION = K('exitDuration');
const EXIT_SCALE = K('exitScale');
const morphEase = K('morphEase');
ctx = gsap.context(() => {
if (reduced) {
gsap.set(loader, { display: 'none' });
gsap.set(pageItems, { autoAlpha: 1 });
return;
}
const counter = { v: 0 };
const tl = gsap.timeline();
// Reset (makes restart() clean)
tl.set(loader, { autoAlpha: 1 })
.set(svg, { scale: 1, transformOrigin: '50% 50%' })
.set(shape, { morphSVG: '#msp-s1', clearProps: 'filter', scale: 0.6, opacity: 0, transformOrigin: '50% 50%' })
.set(outline, { morphSVG: '#msp-s1', drawSVG: '0% 0%', autoAlpha: 1 })
.set(meta, { opacity: 1, y: 0 })
.call(() => { stepLabel.textContent = '01 — Circle'; });
// Intro: the circle pops in while its outline sweeps once
tl.to(shape, { scale: 1, opacity: 1, duration: 0.7, ease: 'back.out(1.6)' }, 0)
.to(outline, { drawSVG: '0% 100%', duration: 0.45, ease: 'power2.out' }, 0.05)
.to(outline, { drawSVG: '100% 100%', duration: 0.4, ease: 'power2.in' }, 0.45);
// Percentage runs across the whole morph sequence
tl.fromTo(counter, { v: 0 }, {
v: 100, duration: MORPH_END, ease: 'power1.inOut', snap: { v: 1 },
onUpdate: () => { pct.textContent = String(counter.v); },
}, 0);
// Morph steps: fill morphs, outline is swapped to the target shape while invisible then traces it
const trace = Math.min(0.5, STEP * 0.56); // 0.5s at the default step; shrinks so the sweep ends before the next swap
SHAPES.slice(1).forEach((name, i) => {
const t = INTRO + i * STEP;
const target = `#msp-s${i + 2}`;
tl.to(shape, { morphSVG: target, duration: STEP, ease: morphEase }, t)
.set(outline, { morphSVG: target, drawSVG: '0% 0%' }, t)
.to(outline, { drawSVG: '0% 100%', duration: trace, ease: 'power2.out' }, t)
.to(outline, { drawSVG: '100% 100%', duration: trace * 0.84, ease: 'power2.in' }, t + trace * 0.96)
.call(() => { stepLabel.textContent = `0${i + 2} — ${name}`; }, null, t);
});
// Exit: kill the glow, swell to fill the viewport, then fade the loader as the page rises
tl.set(shape, { filter: 'none' }, EXIT_AT)
.set(outline, { autoAlpha: 0 }, EXIT_AT)
.to(meta, { opacity: 0, y: -12, duration: 0.35, ease: 'power2.in' }, EXIT_AT)
.to(svg, { scale: EXIT_SCALE, duration: EXIT_DURATION, ease: 'expo.in' }, EXIT_AT)
.to(loader, { autoAlpha: 0, duration: 0.6, ease: 'power2.out' }, EXIT_AT + EXIT_DURATION)
.fromTo(pageItems, { y: 28, opacity: 0 }, { y: 0, opacity: 1, duration: 1, ease: 'power3.out', stagger: 0.08 }, EXIT_AT + EXIT_DURATION - 0.05);
replay.addEventListener('click', () => tl.restart(), { signal: ac.signal });
});
}
build();
// Playground: rebuild with the new values
window.addEventListener('ml:knobs', build);
window.MotionLab?.onCleanup(() => { ctx?.revert(); ac?.abort(); });
```
### EFFECT (Framer & Webflow notes): Morphing SVG Preloader
**Framer**
- Implement as a **Code Component** (Assets → Code → New component) when the effect owns its own markup (hero, gallery, section), or as a **Code Override** when it should animate existing Framer layers.
- Code Component: paste the React version (`"use client"` is not needed in Framer; keep `useGSAP` with a `scope` ref). Install `gsap` and `@gsap/react` from the package panel. Expose the customization knobs as `addPropertyControls` (numbers/colors/enums) so they are editable on the canvas.
- Code Override: `export function withMorphingSVGPreloader(Component) { return (props) => { const ref = useRef(null); useGSAP(() => { /* spec */ }, { scope: ref }); return <Component ref={ref} {...props} /> } }` and apply it to the layer in the right-hand panel.
- Framer already smooth-scrolls; do **not** add Lenis.
- Preview in the Framer preview window, not the canvas (GSAP runs at runtime only).
**Webflow**
- Build the markup in the Designer with **exactly the class names from the HTML structure section** (prefix `msp`), so the script finds the elements.
- Styles: either recreate the CSS requirements as Webflow classes or paste the CSS in **Page settings → Custom code → Inside <head>** inside `<style>`.
- Scripts: **Page settings → Before </body>**:
```html
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/DrawSVGPlugin.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/MorphSVGPlugin.min.js"></script>
<script type="module">
// paste the JS from the animation spec; remove the optional window.MotionLab?.… lines
</script>
```
- Native alternative: parts that are pure show/hide, fade or move can be Webflow Interactions (IX2); keep the timeline sequencing in code — IX2 cannot do stagger/ease control at this precision.
- Custom code only runs on the **published** site or in Preview, never on the Designer canvas.
- Webflow's own smooth scroll is off by default; if you add Lenis, sync it: `lenis.on('scroll', ScrollTrigger.update)`.
---
### EFFECT: Morphing SVG Preloader
**What it does (visual):** The page opens on a solid dark screen. In the centre an off-white circle (about 26vw wide, max 220px) pops in from 60% scale with a slight overshoot, haloed by a soft glow in `#d4ff3a` (18px inner radius, a wider ~3× outer halo). A 1px `#d4ff3a` line sweeps once around a ring just outside the circle and vanishes. Beneath the form a large light serif percentage reads "0%" with the mono label "01 — CIRCLE" under it. Then, every 0.9 seconds, the filled form melts into the next shape — circle → rounded square → organic blob → soft four-point star → a leaf-like logo mark — each morph easing with `power2.inOut`. At the start of each step the outline (already in the shape of the *next* form) draws itself clockwise around the ring and then draws itself away again, so it always traces the form the fill is becoming. The percentage climbs 0 → 100 across the whole morph sequence (integer steps, tabular digits) and the label updates per step ("02 — SQUARE" … "05 — MARK"). At 100% the glow switches off, the outline disappears, the percentage fades up and away, and the mark swells 40× in 0.9s (slow then violently fast) until it covers the viewport; the loader then fades out over 0.6s revealing a page whose nav bar, accent eyebrow, giant serif headline "Shape is a verb.", paragraph and Replay button rise in with a stagger. Replay runs it all again.
**Tech stack:**
- GSAP 3.13+ with MorphSVGPlugin and DrawSVGPlugin (both free since 3.13).
- CDN: `gsap.min.js`, `MorphSVGPlugin.min.js`, `DrawSVGPlugin.min.js` from `https://cdn.jsdelivr.net/npm/gsap@3/dist/`.
- npm: `import gsap from "gsap"; import { MorphSVGPlugin } from "gsap/MorphSVGPlugin"; import { DrawSVGPlugin } from "gsap/DrawSVGPlugin"; gsap.registerPlugin(MorphSVGPlugin, DrawSVGPlugin);`
- Fonts: a variable serif for the percentage and headline (demo: Fraunces 300), a mono for labels.
**HTML structure:**
```html
<div class="msp">
<div class="msp__loader" aria-hidden="true">
<svg class="msp__svg" viewBox="0 0 200 200" width="200" height="200">
<defs>
<path id="msp-s1" d="M100,20 C144.2,20 180,55.8 180,100 C180,144.2 144.2,180 100,180 C55.8,180 20,144.2 20,100 C20,55.8 55.8,20 100,20 Z"/>
<path id="msp-s2" d="M62,30 H138 C155.7,30 170,44.3 170,62 V138 C170,155.7 155.7,170 138,170 H62 C44.3,170 30,155.7 30,138 V62 C30,44.3 44.3,30 62,30 Z"/>
<path id="msp-s3" d="M104,22 C146,16 182,54 178,98 C174,142 148,184 102,180 C56,176 18,146 22,100 C26,54 62,28 104,22 Z"/>
<path id="msp-s4" d="M100,18 C110,66 134,90 182,100 C134,110 110,134 100,182 C90,134 66,110 18,100 C66,90 90,66 100,18 Z"/>
<path id="msp-s5" d="M38,162 C38,86 92,38 162,38 C162,114 108,162 38,162 Z"/>
</defs>
<g class="msp__ring" transform="translate(100 100) scale(1.16) translate(-100 -100)">
<path class="msp__outline" d="(same d as #msp-s1)"/>
</g>
<path class="msp__shape" d="(same d as #msp-s1)"/>
</svg>
<div class="msp__meta">
<p class="msp__pct"><span class="msp__pct-num">0</span><span class="msp__pct-sign">%</span></p>
<p class="msp__step">01 — Circle</p>
</div>
</div>
<main class="msp__page">
<header class="msp__nav"><span class="msp__brand">Forma</span><nav class="msp__links"><a href="#">Typefaces</a><a href="#">Studio</a><a href="#">Journal</a></nav></header>
<div class="msp__hero">
<p class="msp__eyebrow">Type foundry & motion studio</p>
<h1 class="msp__title">Shape is a verb.</h1>
<p class="msp__sub">Five letterforms, one gesture.</p>
<button class="msp__replay" type="button">Replay preloader</button>
</div>
</main>
</div>
```
The five `d` strings above are the exact morph targets (all cubic Béziers in a 200×200 box; point counts differ — MorphSVG's default `shapeIndex: "auto"` handles that).
**CSS requirements:**
- `.msp__loader`: `position: fixed; inset: 0; z-index: 10; background: var(--bg, #0a0a0a); display: grid; place-content: center; justify-items: center; gap: clamp(1.5rem, 4vh, 3rem); pointer-events: none;`
- `.msp__svg`: `width: clamp(140px, 26vw, 220px); height: auto; overflow: visible; will-change: transform;`
- `.msp__shape`: `fill: var(--text, #ededed); filter: drop-shadow(0 0 18px color-mix(in srgb, var(--accent, #d4ff3a) 55%, transparent)) drop-shadow(0 0 calc(18px * 3.1) color-mix(in srgb, var(--accent) 28%, transparent));`
- `.msp__outline`: `fill: none; stroke: var(--accent); stroke-width: 1.1; stroke-linecap: round;` — the parent `<g>` is scaled 1.16 about the centre so the ring sits just outside the fill.
- `.msp__pct`: display serif, `font-weight: 300; font-size: clamp(2.4rem, 6vw, 4.2rem); line-height: 1; letter-spacing: -0.04em; font-variant-numeric: tabular-nums;` — `.msp__pct-sign { font-size: .45em; color: var(--muted); vertical-align: .9em }`.
- `.msp__step`: mono, `11px`, uppercase, `letter-spacing: .18em`, muted, `margin-top: .9rem`.
- `.msp__page`: `min-height: 100vh; display: grid; grid-template-rows: auto 1fr; padding: clamp(1.25rem, 3vw, 2.5rem) 6vw clamp(3rem, 8vw, 6rem);` — `.msp__nav` is a flex row with a `1px solid var(--line)` bottom border; `.msp__title` is `clamp(3rem, 10vw, 10rem)`, `line-height: .9`, `letter-spacing: -0.045em`; `.msp__eyebrow` is mono 11px in `--accent`.
- `.msp__nav, .msp__eyebrow, .msp__title, .msp__sub, .msp__replay` start at `opacity: 0` (JS reveals them).
**Animation spec (exact values):**
Create `const counter = { v: 0 }` and one `tl = gsap.timeline()`. Constants: `STEP = 0.9`, `INTRO = 0.7`, `MORPH_END = INTRO + 4 × STEP` (4.3 at the default 0.9), `EXIT_AT = MORPH_END + 0.25` (4.55 at default), `EXIT_DURATION = 0.9`.
1. Reset block at 0 (so `restart()` is clean): `tl.set('.msp__loader', { autoAlpha: 1 }).set('.msp__svg', { scale: 1, transformOrigin: '50% 50%' }).set('.msp__shape', { morphSVG: '#msp-s1', clearProps: 'filter', scale: 0.6, opacity: 0, transformOrigin: '50% 50%' }).set('.msp__outline', { morphSVG: '#msp-s1', drawSVG: '0% 0%', autoAlpha: 1 }).set('.msp__meta', { opacity: 1, y: 0 })` and a `.call()` that writes "01 — Circle".
2. Intro: `tl.to('.msp__shape', { scale: 1, opacity: 1, duration: 0.7, ease: 'back.out(1.6)' }, 0)`, `tl.to('.msp__outline', { drawSVG: '0% 100%', duration: 0.45, ease: 'power2.out' }, 0.05)`, `tl.to('.msp__outline', { drawSVG: '100% 100%', duration: 0.4, ease: 'power2.in' }, 0.45)`.
3. Counter: `tl.fromTo(counter, { v: 0 }, { v: 100, duration: MORPH_END, ease: 'power1.inOut', snap: { v: 1 }, onUpdate: () => pctNum.textContent = String(counter.v) }, 0)`.
4. Morph steps — for `i = 0..3`, `t = INTRO + i × STEP`, `target = '#msp-s' + (i + 2)`, `trace = min(0.5, STEP × 0.56)` (0.5 at default; shrinks with a short step so the sweep always ends before the next swap):
- fill: `tl.to('.msp__shape', { morphSVG: target, duration: 0.9, ease: 'power2.inOut' }, t)`
- outline swap while invisible: `tl.set('.msp__outline', { morphSVG: target, drawSVG: '0% 0%' }, t)`
- trace in: `tl.to('.msp__outline', { drawSVG: '0% 100%', duration: trace, ease: 'power2.out' }, t)`
- trace out: `tl.to('.msp__outline', { drawSVG: '100% 100%', duration: trace × 0.84, ease: 'power2.in' }, t + trace × 0.96)` (0.42s at +0.48 by default)
- label: `tl.call(() => stepLabel.textContent = '0' + (i + 2) + ' — ' + name, null, t)` with names Square, Blob, Star, Mark.
5. Exit at `EXIT_AT`: `tl.set('.msp__shape', { filter: 'none' }, EXIT_AT)`, `tl.set('.msp__outline', { autoAlpha: 0 }, EXIT_AT)`, `tl.to('.msp__meta', { opacity: 0, y: -12, duration: 0.35, ease: 'power2.in' }, EXIT_AT)`, `tl.to('.msp__svg', { scale: 40, duration: 0.9, ease: 'expo.in' }, EXIT_AT)`.
6. Reveal: `tl.to('.msp__loader', { autoAlpha: 0, duration: 0.6, ease: 'power2.out' }, EXIT_AT + EXIT_DURATION)` and `tl.fromTo(['.msp__nav', '.msp__eyebrow', '.msp__title', '.msp__sub', '.msp__replay'], { y: 28, opacity: 0 }, { y: 0, opacity: 1, duration: 1, ease: 'power3.out', stagger: 0.08 }, EXIT_AT + EXIT_DURATION - 0.05)`.
Total ≈ EXIT_AT + EXIT_DURATION + 1.3 (≈6.7s at defaults).
**Interaction:** Purely time-based; no scroll or cursor dependency. `.msp__replay` click → `tl.restart()`. Nav links lighten on hover (CSS, 0.2s).
**Responsive behavior:** Everything is fluid (`clamp()` sizes, `26vw` SVG); nothing changes structurally under 768px except the third nav link is hidden. The exit scale always covers the viewport as long as `140px × 40` exceeds the longest viewport edge (5600px at the default 40). `gsap.matchMedia()` is not required; if the headline wraps to two lines on phones that is intended.
**Accessibility:** The loader is `aria-hidden="true"` and `pointer-events: none`. Under `prefers-reduced-motion: reduce` do not run the timeline: `display: none` the loader, show the page content at full opacity and hide Replay (do it in CSS and JS). The page content remains in the DOM throughout so screen readers are never blocked by the loader. Replay has a visible focus ring (`outline: 2px solid var(--accent); outline-offset: 3px`).
**Performance rules:** Only `d` (morph), `stroke-dasharray/offset` (draw), `transform` and `opacity` change. Set `filter: none` on the fill *before* the 40× scale — a `drop-shadow` on a multi-thousand-pixel element tanks the frame rate. Never animate `drawSVG` on a path whose `d` is mid-morph (DrawSVG measures length at tween start). Everything is inside `gsap.context()` and reverted on unmount (`ctx.revert()` restores the original `d` attributes too). The host pauses `gsap.globalTimeline` when the demo is off-screen.
**Customization knobs:**
- `STEP` (0.9) — seconds per morph; `INTRO` (0.7).
- The five `d` strings — swap `#msp-s5` for your real logo path (keep it inside the 200×200 box).
- Morph ease `power2.inOut`; add `type: 'rotational'` to `morphSVG` for blobbier transitions.
- Glow radius (18px inner, ×3.1 outer) and colour (`--accent`: #d4ff3a); ring offset via the group's `scale(1.16)`.
- Exit scale (40), exit duration (0.9s) and ease (`expo.in`).
**Acceptance criteria:**
- [ ] The form passes through all five shapes in order, each morph 0.9s `power2.inOut`, with no flicker or path "flip".
- [ ] The accent outline is never visible at a step boundary and always traces the shape the fill is turning into.
- [ ] Percentage reaches exactly 100 as the fifth shape lands (`0.7 + 4 × 0.9`s) and the step label reads "05 — MARK".
- [ ] The glow disappears before the scale-up; the form fully covers the viewport before the loader fades.
- [ ] Page content rises in with a 0.08s stagger; Replay restarts cleanly from the circle at 0%.
- [ ] Reduced-motion users see the page immediately; no console errors.
Source
Full source code
<div class="msp">
<div class="msp__loader" aria-hidden="true">
<svg class="msp__svg" viewBox="0 0 200 200" width="200" height="200">
<defs>
<path id="msp-s1" d="M100,20 C144.2,20 180,55.8 180,100 C180,144.2 144.2,180 100,180 C55.8,180 20,144.2 20,100 C20,55.8 55.8,20 100,20 Z"/>
<path id="msp-s2" d="M62,30 H138 C155.7,30 170,44.3 170,62 V138 C170,155.7 155.7,170 138,170 H62 C44.3,170 30,155.7 30,138 V62 C30,44.3 44.3,30 62,30 Z"/>
<path id="msp-s3" d="M104,22 C146,16 182,54 178,98 C174,142 148,184 102,180 C56,176 18,146 22,100 C26,54 62,28 104,22 Z"/>
<path id="msp-s4" d="M100,18 C110,66 134,90 182,100 C134,110 110,134 100,182 C90,134 66,110 18,100 C66,90 90,66 100,18 Z"/>
<path id="msp-s5" d="M38,162 C38,86 92,38 162,38 C162,114 108,162 38,162 Z"/>
</defs>
<g class="msp__ring" transform="translate(100 100) scale(1.16) translate(-100 -100)">
<path class="msp__outline" d="M100,20 C144.2,20 180,55.8 180,100 C180,144.2 144.2,180 100,180 C55.8,180 20,144.2 20,100 C20,55.8 55.8,20 100,20 Z"/>
</g>
<path class="msp__shape" d="M100,20 C144.2,20 180,55.8 180,100 C180,144.2 144.2,180 100,180 C55.8,180 20,144.2 20,100 C20,55.8 55.8,20 100,20 Z"/>
</svg>
<div class="msp__meta">
<p class="msp__pct"><span class="msp__pct-num">0</span><span class="msp__pct-sign">%</span></p>
<p class="msp__step">01 — Circle</p>
</div>
</div>
<main class="msp__page">
<header class="msp__nav">
<span class="msp__brand">Forma</span>
<nav class="msp__links" aria-label="Site"><a href="#">Typefaces</a><a href="#">Studio</a><a href="#">Journal</a></nav>
</header>
<div class="msp__hero">
<p class="msp__eyebrow">Type foundry & motion studio</p>
<h1 class="msp__title">Shape is a verb.</h1>
<p class="msp__sub">Five letterforms, one gesture. Forma draws type that was designed to move — every glyph has a morph target.</p>
<button class="msp__replay" type="button">Replay preloader</button>
</div>
</main>
</div>
.msp { --msp-accent: var(--knob-accent, var(--accent)); --msp-glow: var(--knob-glow, 18px); position: relative; min-height: 100vh; }
/* ---------- loader ---------- */
.msp__loader { position: fixed; inset: 0; z-index: 10; background: var(--bg); display: grid; place-content: center; justify-items: center; gap: clamp(1.5rem, 4vh, 3rem); pointer-events: none; }
.msp__svg { width: clamp(140px, 26vw, 220px); height: auto; overflow: visible; will-change: transform; }
.msp__shape {
fill: var(--text);
filter: drop-shadow(0 0 var(--msp-glow) color-mix(in srgb, var(--msp-accent) 55%, transparent)) drop-shadow(0 0 calc(var(--msp-glow) * 3.1) color-mix(in srgb, var(--msp-accent) 28%, transparent));
}
.msp__outline { fill: none; stroke: var(--msp-accent); stroke-width: 1.1; stroke-linecap: round; }
.msp__meta { text-align: center; }
.msp__pct { font-family: var(--font-display); font-weight: 300; font-variation-settings: 'opsz' 144; font-size: clamp(2.4rem, 6vw, 4.2rem); line-height: 1; letter-spacing: -0.04em; font-variant-numeric: tabular-nums; color: var(--text); }
.msp__pct-sign { font-size: .45em; color: var(--muted); margin-left: .1em; vertical-align: .9em; }
.msp__step { margin-top: .9rem; font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: .18em; color: var(--muted); }
/* ---------- page ---------- */
.msp__page { min-height: 100vh; display: grid; grid-template-rows: auto 1fr; padding: clamp(1.25rem, 3vw, 2.5rem) 6vw clamp(3rem, 8vw, 6rem); color: var(--text); }
.msp__nav { display: flex; justify-content: space-between; align-items: baseline; padding-bottom: 1.25rem; border-bottom: 1px solid var(--line); opacity: 0; }
.msp__brand { font-family: var(--font-display); font-weight: 400; font-size: 1.35rem; letter-spacing: -0.03em; }
.msp__links { display: flex; gap: clamp(1rem, 2.5vw, 2.25rem); font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: .12em; color: var(--muted); }
.msp__links a { text-decoration: none; }
.msp__links a:hover { color: var(--text); }
.msp__hero { align-self: center; max-width: 1100px; padding-top: clamp(2rem, 6vh, 4rem); }
.msp__eyebrow { font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: .14em; color: var(--msp-accent); opacity: 0; }
.msp__title { font-family: var(--font-display); font-weight: 300; font-variation-settings: 'opsz' 144, 'SOFT' 40; font-size: clamp(3rem, 10vw, 10rem); line-height: .9; letter-spacing: -0.045em; margin: .4em 0 .35em; opacity: 0; }
.msp__sub { max-width: 46ch; color: var(--muted); font-size: clamp(1rem, 1.3vw, 1.2rem); line-height: 1.5; opacity: 0; }
.msp__replay {
margin-top: 2.5rem; padding: .7em 1.4em; border: 1px solid color-mix(in srgb, var(--text) 25%, transparent); border-radius: 999px;
font-size: .85rem; font-weight: 500; opacity: 0; transition: background .25s, color .25s, border-color .25s;
}
.msp__replay:hover { background: var(--text); color: var(--bg); border-color: var(--text); }
.msp__replay:focus-visible { outline: 2px solid var(--msp-accent); outline-offset: 3px; }
@media (max-width: 767px) {
.msp__links a:nth-child(3) { display: none; }
}
@media (prefers-reduced-motion: reduce) {
.msp__loader { display: none; }
.msp__nav, .msp__eyebrow, .msp__title, .msp__sub { opacity: 1; }
.msp__replay { display: none; }
}
// Morphing SVG Preloader — one glowing form morphs through five shapes, then swells to fill the screen.
gsap.registerPlugin(MorphSVGPlugin, DrawSVGPlugin);
// Defaults — overridable from the MOTION/LAB Playground via MotionLab.knob()
const DEFAULTS = { step: 0.9, morphEase: 'power2.inOut', exitScale: 40, exitDuration: 0.9, glowSize: 18, accent: '#d4ff3a' };
const K = (id) => window.MotionLab?.knob(id, DEFAULTS[id]) ?? DEFAULTS[id];
const SHAPES = ['Circle', 'Square', 'Blob', 'Star', 'Mark'];
const INTRO = 0.7; // circle pops in + first outline sweep
const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
const root = document.querySelector('.msp');
const loader = root.querySelector('.msp__loader');
const svg = root.querySelector('.msp__svg');
const shape = root.querySelector('.msp__shape');
const outline = root.querySelector('.msp__outline');
const meta = root.querySelector('.msp__meta');
const pct = root.querySelector('.msp__pct-num');
const stepLabel = root.querySelector('.msp__step');
const pageItems = [root.querySelector('.msp__nav'), root.querySelector('.msp__eyebrow'), root.querySelector('.msp__title'), root.querySelector('.msp__sub'), root.querySelector('.msp__replay')];
const replay = root.querySelector('.msp__replay');
let ctx, ac;
function build() {
ctx?.revert();
ac?.abort();
ac = new AbortController();
root.style.setProperty('--knob-accent', K('accent'));
root.style.setProperty('--knob-glow', K('glowSize') + 'px');
const STEP = K('step'); // seconds per morph
const MORPH_END = INTRO + STEP * (SHAPES.length - 1); // 4.3s at defaults
const EXIT_AT = MORPH_END + 0.25; // 4.55s at defaults
const EXIT_DURATION = K('exitDuration');
const EXIT_SCALE = K('exitScale');
const morphEase = K('morphEase');
ctx = gsap.context(() => {
if (reduced) {
gsap.set(loader, { display: 'none' });
gsap.set(pageItems, { autoAlpha: 1 });
return;
}
const counter = { v: 0 };
const tl = gsap.timeline();
// Reset (makes restart() clean)
tl.set(loader, { autoAlpha: 1 })
.set(svg, { scale: 1, transformOrigin: '50% 50%' })
.set(shape, { morphSVG: '#msp-s1', clearProps: 'filter', scale: 0.6, opacity: 0, transformOrigin: '50% 50%' })
.set(outline, { morphSVG: '#msp-s1', drawSVG: '0% 0%', autoAlpha: 1 })
.set(meta, { opacity: 1, y: 0 })
.call(() => { stepLabel.textContent = '01 — Circle'; });
// Intro: the circle pops in while its outline sweeps once
tl.to(shape, { scale: 1, opacity: 1, duration: 0.7, ease: 'back.out(1.6)' }, 0)
.to(outline, { drawSVG: '0% 100%', duration: 0.45, ease: 'power2.out' }, 0.05)
.to(outline, { drawSVG: '100% 100%', duration: 0.4, ease: 'power2.in' }, 0.45);
// Percentage runs across the whole morph sequence
tl.fromTo(counter, { v: 0 }, {
v: 100, duration: MORPH_END, ease: 'power1.inOut', snap: { v: 1 },
onUpdate: () => { pct.textContent = String(counter.v); },
}, 0);
// Morph steps: fill morphs, outline is swapped to the target shape while invisible then traces it
const trace = Math.min(0.5, STEP * 0.56); // 0.5s at the default step; shrinks so the sweep ends before the next swap
SHAPES.slice(1).forEach((name, i) => {
const t = INTRO + i * STEP;
const target = `#msp-s${i + 2}`;
tl.to(shape, { morphSVG: target, duration: STEP, ease: morphEase }, t)
.set(outline, { morphSVG: target, drawSVG: '0% 0%' }, t)
.to(outline, { drawSVG: '0% 100%', duration: trace, ease: 'power2.out' }, t)
.to(outline, { drawSVG: '100% 100%', duration: trace * 0.84, ease: 'power2.in' }, t + trace * 0.96)
.call(() => { stepLabel.textContent = `0${i + 2} — ${name}`; }, null, t);
});
// Exit: kill the glow, swell to fill the viewport, then fade the loader as the page rises
tl.set(shape, { filter: 'none' }, EXIT_AT)
.set(outline, { autoAlpha: 0 }, EXIT_AT)
.to(meta, { opacity: 0, y: -12, duration: 0.35, ease: 'power2.in' }, EXIT_AT)
.to(svg, { scale: EXIT_SCALE, duration: EXIT_DURATION, ease: 'expo.in' }, EXIT_AT)
.to(loader, { autoAlpha: 0, duration: 0.6, ease: 'power2.out' }, EXIT_AT + EXIT_DURATION)
.fromTo(pageItems, { y: 28, opacity: 0 }, { y: 0, opacity: 1, duration: 1, ease: 'power3.out', stagger: 0.08 }, EXIT_AT + EXIT_DURATION - 0.05);
replay.addEventListener('click', () => tl.restart(), { signal: ac.signal });
});
}
build();
// Playground: rebuild with the new values
window.addEventListener('ml:knobs', build);
window.MotionLab?.onCleanup(() => { ctx?.revert(); ac?.abort(); });