SVG Stroke Page Transition — Two Self-Drawing Squiggle Strokes That Swell to Wipe the Screen
Goal
Build a tiny multi-page site (three full-screen hero "pages": Home / About / Contact) with a fixed top navbar. The star effect is the page transition: clicking a nav link runs a two-phase GSAP sequence over two full-screen, winding inline-SVG paths. Leave phase — both squiggly strokes *draw themselves in* (classic strokeDasharray/strokeDashoffset line-draw) while their stroke-width swells from 200 to 700, so the two fattening ribbons cover the entire viewport and wipe out the old page. Then the visible page is swapped underneath. Enter phase — the strokes *keep drawing out* the same direction (dashoffset continues past zero into negative) while the stroke-width thins back from 700 to 200, uncovering the new page. It is entirely click-driven — no scroll, no autoplay. A small hand-rolled "fake router" toggles which hero is visible (this stands in for a framework router like next-transition-router).
Tech
Vanilla HTML/CSS/JS with ES module imports, in a fresh Vite project. Install and import from npm:
gsap(3.x) only. No GSAP plugins, no ScrollTrigger, no Lenis, no SplitText.
import gsap from "gsap";
No gsap.registerPlugin(...) — nothing to register.
Layout / HTML
A <nav>, one fixed full-screen .transition-svg overlay holding the two paths, and three .hero.page sections (only the active one is displayed). Class names and data-route attributes are load-bearing (the JS queries them).
<nav class="navbar">
<div class="navbar-logo">
<div class="navbar-item">
<a href="/" data-route="/">Motionprompts</a>
</div>
</div>
<div class="navbar-items">
<div class="navbar-item"><a href="/" data-route="/">Home</a></div>
<div class="navbar-item"><a href="/about" data-route="/about">About</a></div>
<div class="navbar-item"><a href="/contact" data-route="/contact">Contact</a></div>
</div>
</nav>
<div class="transition-svg">
<svg viewBox="0 0 2453 2535" fill="none" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="none">
<path
d="M227.549 1818.76C227.549 1818.76 406.016 2207.75 569.049 2130.26C843.431 1999.85 -264.104 1002.3 227.549 876.262C552.918 792.849 773.647 2456.11 1342.05 2130.26C1885.43 1818.76 14.9644 455.772 760.548 137.262C1342.05 -111.152 1663.5 2266.35 2209.55 1972.76C2755.6 1679.18 1536.63 384.467 1826.55 137.262C2013.5 -22.1463 2209.55 381.262 2209.55 381.262"
stroke="var(--transition-stroke-1)" stroke-width="200" stroke-linecap="round" />
<path
d="M1661.28 2255.51C1661.28 2255.51 2311.09 1960.37 2111.78 1817.01C1944.47 1696.67 718.456 2870.17 499.781 2255.51C308.969 1719.17 2457.51 1613.83 2111.78 963.512C1766.05 313.198 427.949 2195.17 132.281 1455.51C-155.219 736.292 2014.78 891.514 1708.78 252.012C1437.81 -314.29 369.471 909.169 132.281 566.512C18.1772 401.672 244.781 193.012 244.781 193.012"
stroke="var(--transition-stroke-2)" stroke-width="200" stroke-linecap="round" />
</svg>
</div>
<section class="hero page active" data-route="/"><h1>Home</h1></section>
<section class="hero page" data-route="/about"><h1>About</h1></section>
<section class="hero page" data-route="/contact"><h1>Contact</h1></section>
<script type="module" src="./script.js"></script>
Notes on structure:
- Keep both
<path>dstrings exactly as above — this precise, chaotic, screen-spanning squiggle geometry (in the2453 × 2535viewBox) is what makes the two thick strokes blanket the viewport when they fatten.preserveAspectRatio="none"lets the SVG stretch to the container's aspect ratio (so the strokes fill any window shape).stroke-width="200"andstroke-linecap="round"are the resting values. - Path 1 is the light-grey stroke, path 2 is the magenta stroke (colors below). Order matters: magenta is painted on top of grey.
- Exactly three
.hero.pagesections, each with a uniquedata-route(/,/about,/contact) and an<h1>. Only the first (/) starts with theactiveclass. - Labels are neutral demo text (Home / About / Contact, logo "Motionprompts") — no real brands.
Styling
Fonts (Google Fonts): Barlow Condensed (weights 100–900, used for the huge hero headings) and DM Sans (variable optical-size + weight, used for the nav links).
<link href="https://fonts.googleapis.com/css2?family=Barlow+Condensed:wght@100;200;300;400;500;600;700;800;900&family=DM+Sans:opsz,wght@9..40,100..1000&display=swap" rel="stylesheet" />
Palette (CSS variables — exact hex):
:root {
--bg: #f0f2ef; /* off-white — hero page background */
--fg: #171717; /* near-black — text */
--transition-stroke-1: #d8d9d7; /* light warm grey — path 1 */
--transition-stroke-2: #ff44fe; /* electric magenta/pink — path 2 (the loud one) */
--font-barlow-condensed: "Barlow Condensed", sans-serif;
--font-dm-sans: "DM Sans", sans-serif;
}
Global reset: * { margin:0; padding:0; box-sizing:border-box; }.
Load-bearing CSS:
.navbar:position:fixed; width:100%; padding:1rem;flexjustify-content:space-between; align-items:center;z-index:2;(sits above the heroes, below the transition overlay)..navbar-items:display:flex; gap:clamp(1rem,4vw,2rem);..navbar-item:padding:1rem;..navbar-item a:text-decoration:none; color:var(--fg); font-family:var(--font-dm-sans); font-size:1.125rem; font-weight:500; letter-spacing:-2%;..hero:position:relative; width:100%; height:100svh; background-color:var(--bg); color:var(--fg);flexjustify-content:center; align-items:center;overflow:hidden;..hero h1:text-transform:uppercase; font-family:var(--font-barlow-condensed); font-size:clamp(5rem,15vw,20rem); font-weight:800; letter-spacing:-2%; line-height:1;— a single gigantic centered word.- Fake-router page visibility:
.page { display:none; }and.page.active { display:flex; }— only the active hero is in the layout; the JS swaps theactiveclass. - The transition overlay — critical:
``css .transition-svg { position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%) scale(1.5); /* over-scaled so the strokes overshoot the edges */ width: 100%; height: 100%; pointer-events: none; /* never blocks clicks */ z-index: 100; /* on top of everything */ } .transition-svg svg { width: 100%; height: 100%; } ` The scale(1.5) blow-up plus preserveAspectRatio="none"` ensures the fattened strokes fully cover the viewport with no gaps at the corners.
- Initial hidden dashes (before JS runs):
.transition-svg path { stroke-dashoffset:99999; stroke-dasharray:99999; }— a large placeholder dash so nothing paints until the JS measures each path and overwrites these with the real length. Also mirror the stroke colors in CSS as a fallback for the presentation-attributevar():.transition-svg path:first-of-type { stroke:var(--transition-stroke-1); }and.transition-svg path:last-of-type { stroke:var(--transition-stroke-2); }.
GSAP effect (the important part — be exhaustive)
1. Prime both paths for line-drawing
On load, grab the SVG, collect its two paths, and for each path measure its length and set strokeDasharray === strokeDashoffset === length so the whole squiggle starts fully hidden ("off the end"):
const svg = document.querySelector(".transition-svg svg");
const paths = Array.from(svg.querySelectorAll("path"));
paths.forEach((path) => {
const length = path.getTotalLength();
path.style.strokeDasharray = length;
path.style.strokeDashoffset = length; // one dash the full length, pushed entirely off → invisible
});
2. The leave phase — draw the strokes in + swell them to wipe the screen
A timeline whose onComplete fires the caller's next callback. For each path, a .to() is inserted at position 0 (so both paths animate in parallel, no stagger):
function leave(next) {
const tween = gsap.timeline({ onComplete: next });
paths.forEach((path) => {
tween.to(path, {
strokeDashoffset: 0, // length → 0: the squiggle draws itself on start→end
attr: { "stroke-width": 700 }, // animate the SVG ATTRIBUTE 200 → 700: strokes fatten to cover the screen
duration: 1,
ease: "power1.inOut",
}, 0);
});
return tween;
}
- Both properties tween together over
duration: 1onease: "power1.inOut". strokeDashoffset: length → 0slides the single full-length dash into view, drawing the line progressively.attr: { "stroke-width": 700 }animates the SVG presentation attribute (not the CSS prop) from its resting200up to700. At700, combined with the over-scaled container and the winding geometry, the two ribbons blanket the entire viewport → the old page is fully wiped.- No
delay, nostagger. The two per-path tweens both live at label0, so they are simultaneous. Timeline span =1s.
3. The enter phase — keep drawing out + thin the strokes to reveal
Symmetric timeline, again per-path at position 0, onComplete → next:
function enter(next) {
const tween = gsap.timeline({ onComplete: next });
paths.forEach((path) => {
const length = path.getTotalLength();
tween.to(path, {
strokeDashoffset: -length, // 0 → -length: dash continues the SAME direction, un-drawing off the end
attr: { "stroke-width": 200 }, // 700 → 200: strokes thin back down, uncovering the new page
duration: 1,
ease: "power1.inOut",
onComplete: () => {
gsap.set(path, { strokeDashoffset: length }); // reset to the hidden +length start for the next transition
},
}, 0);
});
return tween;
}
strokeDashoffset: 0 → -lengthpushes the dash past the far end, so the drawn line slides off in the same drawing direction (it does not rewind) — the stroke "keeps going" as it disappears.attr: { "stroke-width": 200 }animates the width back down from700(whereleaveleft it) to the resting200, revealing the freshly-swapped page underneath.- Same
duration: 1,ease: "power1.inOut", parallel (position0), no stagger/delay. - Per-path
onCompletedoes agsap.set(path, { strokeDashoffset: length })— snapping the offset from-lengthback to+length(both are the fully-hidden state) so the nextleavestarts clean.
4. The fake router (glue — this is what a framework router would do)
const pages = document.querySelectorAll(".page");
let currentRoute = "/";
let isTransitioning = false;
function showPage(route) {
pages.forEach((page) => page.classList.toggle("active", page.dataset.route === route));
}
function navigate(route) {
if (isTransitioning || route === currentRoute) return; // guard: ignore during a transition or a no-op click
isTransitioning = true;
leave(() => { // 1) strokes draw in + swell → screen fully covered
showPage(route); // 2) swap the visible hero UNDER the cover
currentRoute = route;
enter(() => { // 3) strokes draw out + thin → new hero revealed
isTransitioning = false; // 4) release the guard
});
});
}
document.querySelectorAll(".navbar a").forEach((link) => {
link.addEventListener("click", (e) => {
e.preventDefault();
navigate(link.dataset.route);
});
});
- Sequence per click:
leave(1s) → swap page →enter(1s) ≈ a 2s total transition. isTransitioningblocks re-entrancy; clicking the current route is a no-op.showPageruns between the two phases, at the moment the screen is fully covered, so the swap is never visible.
No ScrollTrigger, no SplitText, no CustomEase, no lerp/rAF loop, no Three.js. The entire effect is two parallel per-path timeline tweens (strokeDashoffset + animated stroke-width attr) on power1.inOut, phased leave-then-enter.
Assets / images
None. There are no image assets — the whole visual is CSS type + the two inline SVG strokes. Do not add images.
Behavior notes
- Trigger: click on any navbar link only. No scroll, no hover, no autoplay, no loops.
- The transition overlay is
pointer-events:noneand only paints during a transition (strokes hidden at rest), so it never obstructs the page. - Colors carry the mood: at the wipe peak the screen is filled by the electric-magenta
#ff44festroke over the light-grey#d8d9d7stroke — a loud flash between two calm off-white heroes. - No reduced-motion guard and no responsive media queries in the original; the hero type and nav gap already scale via
clamp().100svhheroes.