All components

Silhouette Page Transition

GSAP animation component · Published 2026-07-27 · by vanguardia.dev

Open live demo ↗ Raw prompt (.md)

What it does

Full-page route transition where clicking a nav link runs a GSAP timeline that wipes 20 vertical blocks across the screen (scaleX stagger), then draws an SVG logo via strokeDashoffset and fills it before the incoming page is revealed with a reverse block wipe. Page headings animate in with a SplitText masked-character stagger and the archive route uses Lenis smooth scroll.

How it's built

Categorytransition
Techgsap, lenis
GSAP pluginsScrollTrigger, SplitText
Complexitypage
Performance costmedium
Mobile-safeyes
Scrollhijacks scrolling

page-transition block-wipe svg-draw splittext gsap-timeline lenis editorial minimal fake-router

Rebuild it with AI

To reproduce this animation in your own project, copy the prompt below into Claude Code, Cursor or any AI coding agent. The prompt is validated — it describes the exact structure, timing and easing, so the agent rebuilds the effect faithfully and you can then adapt colors, copy and layout to your design.

The full prompt

Silhouette Page Transition — Block Wipe + SVG Logo Draw

Goal

Build a minimal editorial multi-page demo ("Silhouette") with a cinematic full-page route transition: clicking a nav link makes 20 vertical dark blocks wipe across the screen left→right (staggered scaleX), then a full-screen dark overlay appears where a line-art logo draws itself via strokeDashoffset and fills in; the overlay fades, the new page is swapped in underneath, and the blocks wipe away right→left to reveal it. Page headings animate in with a SplitText masked character reveal, and the archive route scrolls with Lenis smooth scroll. There is no real navigation — a tiny fake client-side router swaps innerHTML.

Tech

Vanilla HTML/CSS/JS with ES module imports. Use gsap (npm) plus the GSAP plugins SplitText and ScrollTrigger, and lenis (npm) for smooth scroll on one route. Register with gsap.registerPlugin(SplitText, ScrollTrigger). Initialize on DOMContentLoaded (or immediately if the document is already ready).

Layout / HTML

Class names and ids are load-bearing — the JS/CSS query them:

<nav>
  <div class="nav-logo"><a href="/">Silhouette</a></div>
  <div class="nav-links">
    <a href="/">Index</a>
    <a href="/archive">Archive</a>
    <a href="/contact">Contact</a>
  </div>
</nav>

<div class="transition-overlay" id="transition-overlay"></div>   <!-- empty; 20 .block divs injected by JS -->

<div class="logo-overlay" id="logo-overlay">
  <div class="logo-container">
    <svg id="logo" width="160" height="160" viewBox="-4 -4 133 136" fill="none">
      <path d="M82.6306 0C79.8604 5.32092 74.9984 15.6531 72.43 24.0313C80.497 18.2644 89.0129 13.5149 97.6896 10.449C93.9825 17.5694 87.0092 32.5146 84.7598 42.5941C93.0521 37.1488 101.702 32.6834 110.474 29.7215C105.427 39.0923 95.1513 60.5111 94.4257 71.2193C83.5883 74.5743 52.906 88.8011 18.5906 118.443C25.5824 101.301 45.556 73.6638 70.6591 53.0204C57.6282 59.6057 38.4488 71.4317 17.8355 89.7486C22.896 76.8262 36.1412 57.0952 53.4438 40.1036C42.5167 46.1741 28.2058 55.6353 13 69.1471C20.7367 49.3908 50.4126 11.3841 82.6306 0Z"
        fill="none" stroke="#e3e4d8" stroke-width="1.5"
        stroke-linecap="round" stroke-linejoin="round" />
    </svg>
  </div>
</div>

<div id="page-content"></div>   <!-- fake router injects the current route's markup here -->

The logo is a single continuous <path> — an abstract flame/leaf-like silhouette of swooping strokes (use the exact path data above so the stroke-draw reads the same; if you must substitute, it MUST remain one single <path> because the draw effect relies on path.getTotalLength()).

Routes (JS object, keyed by pathname)
"/"         → <div class="container"><div class="page-header"><h1>Timeless Form</h1></div></div>
"/archive"  → <div class="container"><div class="archive">
                 <img src="..."/><img src="..."/><img src="..."/><img src="..."/>
               </div></div>
"/contact"  → <div class="container"><div class="page-header"><h1>Get in touch</h1></div></div>

Styling

Fonts (Google Fonts): Barlow Condensed (body + headings, load weights up to 800) and DM Mono (links).

Palette: page background #e3e4d8 (pale sage/bone), text #141414, blocks + logo overlay #222, logo stroke/fill #e3e4d8.

  • * { margin:0; padding:0; box-sizing:border-box }; body { font-family:"Barlow Condensed", sans-serif }.
  • img { width:100%; height:100%; object-fit:cover }.
  • h1: text-transform:uppercase; color:#141414; font-size:12rem; font-weight:800; line-height:1. At max-width:900pxfont-size:2rem.
  • a: text-decoration:none; text-transform:uppercase; color:#141414; font-family:"DM Mono"; font-size:0.9rem; font-weight:500.
  • nav: position:fixed; width:100vw; padding:2rem; display:flex; justify-content:space-between; align-items:center; z-index:1. .nav-logo a: Barlow Condensed, 1.25rem, weight 700. .nav-links: display:flex; gap:2rem.
  • .container: position:relative; width:100%; height:100%; min-height:100svh; background-color:#e3e4d8.
  • .page-header: width:100vw; height:100svh; display:flex; justify-content:center; align-items:center; padding:2rem (heading dead-center).
  • .archive: width:30%; margin:0 auto; padding:15rem 2rem; display:flex; flex-direction:column; gap:2rem. .archive img { aspect-ratio:5/7 } — a narrow centered column of portrait photos.
  • .transition-overlay: position:fixed; top:0; left:0; width:100vw; height:100svh; display:flex; pointer-events:none; z-index:2.
  • .block (created in JS): flex:1; height:100%; background:#222; transform:scaleX(0); transform-origin:left — 20 of them side by side fill the row, each a thin vertical column.
  • .logo-overlay: position:fixed; top:0; left:0; width:100vw; height:100svh; z-index:10000; display:flex; justify-content:center; align-items:center; background:#222; pointer-events:none; opacity:0.
  • .logo-container: width:200px; height:200px; display:flex; justify-content:center; align-items:center; padding:20px.
  • .char (SplitText char wrappers): position:relative; display:inline-block; will-change:transform.

Z stack: nav 1 < transition blocks 2 < logo overlay 10000.

GSAP effect (be exact)

State

let blocks = [], isTransitioning = false, pathLength = 0, currentPath = "/", currentSplit = null, revealTimeout = null;

Init (once)
  1. Create blocks: clear #transition-overlay, append 20 div.block, keep them in the blocks array.
  2. gsap.set(blocks, { scaleX: 0, transformOrigin: "left" }).
  3. Prime the logo path: pathLength = path.getTotalLength(); gsap.set(path, { strokeDasharray: pathLength, strokeDashoffset: pathLength, fill: "transparent" }) — the path starts fully undrawn and unfilled.
  4. renderPage("/") then revealPage() (so the initial load also plays the outgoing block wipe over the home heading reveal).
  5. Attach a click handler to every a[href^="/"] (nav links).
Click handler → handleRouteChange
  • While isTransitioning, preventDefault() and bail.
  • Let modified clicks pass through untouched (metaKey || ctrlKey || shiftKey || altKey || button !== 0 || target === "_blank").
  • Otherwise preventDefault(), resolve new URL(href).pathname; if it differs from currentPath, set isTransitioning = true and run coverPage(url).
coverPage(url) — the cover timeline (the star)

Set pointer-events:auto on both overlays, then build one gsap.timeline({ onComplete: () => navigateTo(url) }):

  1. Block wipe in: tl.to(blocks, { scaleX: 1, duration: 0.4, stagger: 0.02, ease: "power2.out", transformOrigin: "left" }) — each column grows from its left edge, staggering left→right across the screen (total ≈ 0.4 + 19×0.02 ≈ 0.78s) until the page is fully covered by the #222 wall.
  2. Logo overlay pops on: .set(logoOverlay, { opacity: 1 }, "-=0.2") — an instant set 0.2s before the wipe finishes (no fade in).
  3. Reset the draw: .set(path, { strokeDashoffset: pathLength, fill: "transparent" }, "-=0.25") — re-primed every transition so it redraws each time.
  4. Stroke draw: .to(path, { strokeDashoffset: 0, duration: 2, ease: "power2.inOut" }, "-=0.5") — the silhouette draws itself over 2s, slow-fast-slow.
  5. Fill fade: .to(path, { fill: "#e3e4d8", duration: 1, ease: "power2.out" }, "-=0.5") — starts 0.5s before the draw ends; the outline floods with the pale fill.
  6. Logo overlay fades off: .to(logoOverlay, { opacity: 0, duration: 0.25, ease: "power2.out" }) — revealing the solid block wall still standing beneath it.

onCompletenavigateTo(url).

navigateTo(url)

currentPath = urlrenderPage(url)window.scrollTo(0, 0)revealPage().

revealPage() — the outgoing wipe
  • gsap.set(blocks, { scaleX: 1, transformOrigin: "right" }) (flip the origin).
  • gsap.to(blocks, { scaleX: 0, duration: 0.4, stagger: 0.02, ease: "power2.out", transformOrigin: "right" }) — each column collapses toward its right edge, staggering across and uncovering the new page. onComplete: isTransitioning = false and pointer-events:none on both overlays.
  • Safety net: clear any previous timeout, then setTimeout(…, 1000): if the first block still has gsap.getProperty(block, "scaleX") > 0, force all blocks to scaleX: 0 (duration: 0.2, ease: "power2.out", transformOrigin: "right") with the same onComplete cleanup.
renderPage(path) — fake router
  1. If a previous SplitText exists → split.revert(); kill all ScrollTriggers (ScrollTrigger.getAll().forEach(st => st.kill())); destroy Lenis + cancel its rAF if running.
  2. content.innerHTML = routes[path] || routes["/"].
  3. If path === "/archive" → start Lenis: lenis = new Lenis() plus a requestAnimationFrame loop calling lenis.raf(time).
  4. Otherwise → apply the Copy reveal to the page's h1 with delay: 0.3.
Copy reveal (SplitText heading animation)
const split = SplitText.create(element, { type: "chars", mask: "chars", charsClass: "char++" });
gsap.set(split.chars, { y: "100%" });
gsap.to(split.chars, {
  y: "0%", duration: 1, stagger: 0.03, ease: "power4.out", delay,   // delay = 0.3
  scrollTrigger: { trigger: element, start: "top 75%", once: true },
});

Each character starts translated 100% below its own overflow-hidden mask line and slides up into place with a fast-deceleration ease, 0.03s apart left→right. The ScrollTrigger (once: true) fires immediately for the centered headings (they're already in view), so it reads as a load-in reveal happening behind/under the block wipe-out. Keep the returned split instance so it can be revert()-ed on the next route change.

Assets / images

4 editorial portrait photographs, displayed at aspect-ratio 5:7 (portrait) with object-fit: cover, stacked vertically in the narrow centered archive column. Warm, intimate fashion/beauty photography works best — e.g. an extreme close-up of a freckled face half-covered by a striped towel; a sunlit swimsuit portrait against sky and sea; a wet-hair beauty close-up; a low-angle portrait in a sheer red top on a cream backdrop. No brands or text in the images.

Behavior notes

  • Triggers: the transition runs on nav-link click only; the heading reveal runs per route render (immediately-firing ScrollTrigger); the initial page load plays revealPage() once (quick right-origin wipe-out) over the home heading reveal.
  • Re-entrancy: isTransitioning blocks double-clicks mid-transition; overlays toggle pointer-events so the page is inert while covered.
  • Total transition time ≈ 3s cover (wipe + draw + fill + fade) then ≈ 0.8s reveal.
  • Lenis only on /archive (the only scrollable route) and is destroyed on every route change; other routes use native (non-)scroll. window.scrollTo(0,0) after each swap.
  • Blocks are created once at init and reused for every transition (origins flipped via transformOrigin in the tweens).
  • Responsive: only the h1 shrinks (12rem2rem at ≤900px); the archive column stays 30% width. Uses 100svh throughout so mobile browser chrome doesn't clip full-screen layers. No reduced-motion guard in the original.

Images

This component ships with 4 reference assets, served publicly. Use them as-is to reproduce the demo faithfully, then swap in your own — the layout expects the same aspect ratios.

https://motionprompts.dev/c/madeinuxstudio-page-transition/img_01.jpg
https://motionprompts.dev/c/madeinuxstudio-page-transition/img_02.jpg
https://motionprompts.dev/c/madeinuxstudio-page-transition/img_03.jpg
https://motionprompts.dev/c/madeinuxstudio-page-transition/img_04.jpg

They are hotlinkable for prototyping. For anything you ship, replace them: they are licensed for demonstration of this component, not for redistribution.

Using this outside its demo page

This component is written as a complete page — that is how the demo is meant to look. If you are dropping it into an existing project, or combining it with other components, these are the things it declares at document level and that you need to move or reconcile first.

  • Palette on :root--bone, --ink, --graphite, --mute. These names are not namespaced and they collide: --ink is defined by 164 of the 219 components in this catalogue, --paper by 94, --muted by 80, each with different values — and they will also collide with whatever your own project defines. Move them onto the component's wrapper (.my-section { --ink: … }) or rename them with a prefix.
  • **Rules on *, body** — the demo owns the whole document, so these set the page background, typography and resets. Dropped into an existing project they restyle the entire page, not just this section. Re-target them at the component's wrapper before using it.
  • Smooth scroll (Lenis) — this creates its own Lenis instance, and a page may only have one. If your project already runs Lenis, drop the setup shown above and reuse the existing instance, keeping the lenis.on("scroll", ScrollTrigger.update) wiring once. Two instances fight over the same scroll and stutter visibly, with no error in the console.
  • Full-screen overlay — a fixed element covers the viewport (a loader or transition). Only one may exist per page and it must remove itself when done. If your page already has one, keep that and drop this; otherwise the second silently hides the first.

Adapting this to React

Everything above describes a standalone document, and this one leans on that harder than most: it is not just an animated widget dropped onto a page, it is the page — its own fake router (routes, currentPath, renderPage), the nav bar whose links it intercepts, and the two overlays that cover the swap. React withdraws the guarantees a standalone script relies on (one run, document.querySelector reaching anywhere, no teardown) at once and quietly: the component renders, the first block wipe looks right, and it misbehaves in a way that does not point back at any of this.

Under React 19 with StrictMode, every effect mounts, unmounts, and mounts again before anything reaches the screen. Here that means createBlocks() runs twice against the same #transition-overlay, and since it does not check for existing children before appending, the second pass wipes the node's innerHTML and rebuilds fresh — the leak is not doubled blocks, it's that the first blocks array now points at 20 detached nodes while a second revealPage() starts a wipe-out against them at the same time a second one starts against the live set. Worse, document.querySelectorAll('a[href^="/"]').forEach(link => link.addEventListener(...)) runs twice with no matching removal, so every nav <a> ends up with two click listeners: one click fires handleRouteChange twice, and the second call reads isTransitioning before the first call's coverPage() has had a chance to set it, so both build a gsap.timeline() against the same 20 blocks and race to call navigateTo(). None of this reproduces in a production build — only in development, only during the double-mount — which is exactly why it has to be designed out rather than tested away. Treat the cleanup as part of the effect, not an afterthought.

*(1) The entry point* — The script checks document.readyState before subscribing to DOMContentLoaded, and only once the document is ready does it call init() — the function that creates the 20 blocks, primes the logo path's strokeDasharray, renders the initial route, plays the opening revealPage() wipe-out, and wires the nav clicks. That guard exists to survive being loaded late in a plain document; useEffect already runs after the DOM is committed, so the guard and the DOMContentLoaded listener are both dead weight. Move the body of init() as-is into a useEffect with an empty dependency array — including the initial renderPage(currentPath) / revealPage() pair, which is part of mount, not part of navigation.

*(2) Element lookups* — getElementById("page-content"), getElementById("transition-overlay"), getElementById("logo-overlay"), getElementById("logo"), and the nav's querySelectorAll('a[href^="/"]') all assume this component owns the document. Give the root element — the one wrapping the <nav>, both overlays, and #page-content together, since the click interceptor has to reach the nav that sits beside the routed content, not just the content itself — a ref, and resolve every one of those lookups off it. If you split this apart later (nav living in a persistent app shell, #page-content in a routed subtree underneath), the click interception has to move up to wherever the nav actually renders; scoping it to the routed subtree alone would leave the nav's clicks unintercepted. During the StrictMode remount two copies of this subtree exist for an instant, and an unscoped document.getElementById will happily bind overlay/logoOverlay to the copy on its way out.

*(3) Cleanup* — Two things create GSAP objects here on different schedules, and only one of them is inside the synchronous window gsap.context can see. The block-priming (gsap.set on the 20 blocks) and the path's dash setup run once, synchronously, during mount — a plain gsap.context factory catches those. coverPage() and revealPage() do not: they build a fresh gsap.timeline() / gsap.to() from a nav click or from a timeline's own onComplete, both long after the factory has returned, so a context that only wraps the mount body never sees them and ctx.revert() leaves whatever wipe is in flight running. Register both as named methods with the context argument instead of calling them directly, and call them through the context from then on:

useEffect(() => {
  const ctx = gsap.context((self) => {
    createBlocks();
    gsap.set(blocks, { scaleX: 0, transformOrigin: "left" });
    primeLogoPath();
    self.add("coverPage", coverPage);
    self.add("revealPage", revealPage);
    renderPage(currentPathRef.current);
    self.revealPage();
  }, rootRef);
  document.querySelectorAll('a[href^="/"]').forEach((link) =>
    link.addEventListener("click", onAnchorClick)
  );
  return () => ctx.revert();
}, []);

and inside coverPage's onComplete, call ctx.revealPage() rather than the bare revealPage() reference — the reveal it triggers on navigation needs the same tracking the initial one gets. isTransitioning, currentPath, pathLength and the revealTimeout handle belong in refs, not state: nothing here reads them to decide what to render, and a StrictMode remount that resets a plain module variable is exactly how the double-click race above happens — a ref survives the remount because it lives on the fiber, not in the closure init() recreates.

The remaining three pieces sit outside anything gsap.context tracks. ScrollTrigger.getAll().forEach(st => st.kill()), called at the top of every renderPage() to clear the previous route's heading reveal, kills every ScrollTrigger on the page, not just this component's — harmless in the standalone demo where it's the only trigger-owning script running, but a real hazard next to any other ScrollTrigger-driven component. Keep the tween applyCopy returns instead and kill tween.scrollTrigger specifically. The revealTimeout safety net (the one-second fallback that force-finishes a stuck wipe) has to be cleared in the same effect cleanup — an unmount mid-transition otherwise leaves it armed to call gsap.to on blocks React has already unmounted. And Lenis: destroyLenis() today only runs from inside the next renderPage() call, which means unmounting while /archive is showing leaves lenis and its requestAnimationFrame loop (lenisRaf) running forever, since no further navigation ever arrives to trigger the teardown. Call destroyLenis() unconditionally in the effect's cleanup, in addition to the per-navigation call — it's a no-op when Lenis isn't active. Note this component never needs the usual lenis.on("scroll", ScrollTrigger.update) relay: Lenis only ever runs on /archive, which has no ScrollTrigger, and the char-reveal ScrollTrigger only ever runs on the routes where Lenis is torn down — the two are never live at once, so wiring them together would be dead code, not a fix.

SplitText follows the same reasoning as the block timelines: applyCopy runs from inside renderPage, itself invoked from a click handler or from coverPage's onComplete, never from the mount factory. Register renderPage with self.add alongside coverPage/revealPage so the split's masked-character tween and its once: true ScrollTrigger get grouped into the same context, and keep the existing currentSplit.revert() at the top of renderPage — that guards against splitting already-split output across route changes, which ctx.revert() at unmount does not address, since it only fires once, at the end.