Nuvoro Page Transitions — View Transitions API + GSAP revealer + masked SplitText
Goal
Build a minimal editorial four-view single-page site (home, work, studio, contact) for a fictional studio called "nuvoro". The star effect: clicking a nav link swaps views through the native View Transitions API — the incoming page is unmasked by a full-screen clip-path polygon that expands from a thin horizontal slit at 75% viewport height to the whole screen over 2s — while a dark GSAP "revealer" overlay wipes away vertically and the new view's heading reveals through masked SplitText chars/words/lines.
Tech
Vanilla HTML/CSS/JS with ES module imports. Use gsap (npm) plus the GSAP plugins SplitText and CustomEase, and lenis (npm) for smooth scrolling. Register the plugins with gsap.registerPlugin(SplitText, CustomEase). No frameworks, no build-specific code beyond ES imports (assume a Vite dev server).
Layout / HTML
- Fixed top nav (
.nav) spanning the viewport width with two columns: .col1 (flex: 1):.nav-logocontaining<a href="/" data-path="/">nuvoro</a>..col2 (flex: 2,display:flex; justify-content: space-between):.nav-itemswith three.nav-itemdivs —work,studio,contact— each wrapping an<a href="/work" data-path="/work">-style anchor, plus a.nav-copywith<p>toronto, ca</p>..nav-itemshas NO layout rule of its own — do NOT make it a horizontal flex row. Since the anchors aredisplay:block(~0.85rem), the three.nav-itemdivs stack vertically as a compact column list sitting at the left edge of this second column, while the column'sjustify-content: space-betweenpushes.nav-copy("toronto, ca") to the right edge.<main id="views">containing four<section class="view" data-path="...">elements. Only the home view (data-path="/") is visible initially; the other three carry thehiddenattribute. Every view's first child is an empty<div class="revealer"></div>(the dark wipe overlay).- Home (
data-path="/"):.homewrapper with.header > h1readingnuvoro(huge, centered) and.hero-img > img(full-width image anchored to the bottom of the viewport). - Work (
data-path="/work"):.workwrapper with<h1>selected work</h1>and.projectscontaining 4<img>stacked vertically. - Studio (
data-path="/studio"):.studiowrapper with two.cols — first holds<h2 class="studio-header">Our Story</h2>, second holds a long<h2>paragraph (4–5 lines of studio-manifesto copy about creativity, bold ideas and crafted storytelling) followed by.about-img > img. - Contact (
data-path="/contact"):.contactwrapper with two.cols — first holds<h2>Contact Us</h2>, second holds two.contact-copyblocks (each two<h2>s: a label like "Collaborations"/"Inquiries" and an email likestudio@nuvoro.com/support@nuvoro.com) plus a.socialsrow of three<p>s (Instagram, Twitter, LinkedIn) pinned to the bottom of the viewport.
Styling
- CSS variables:
--bg: #fff,--fg: #0a0a0a. Global reset (* { margin:0; padding:0; box-sizing:border-box }). - Font: Inter variable from Google Fonts. The import MUST include the optical-size (
opsz) axis — use exactly:
`` @import url("https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap"); ` then body { font-family: "Inter"; color: var(--fg) }. Why opsz is load-bearing: the home wordmark is set at font-size: 30.5vw (~439px on a 1440px viewport). With the opsz axis present, font-optical-sizing: auto snaps Inter to its narrower display metrics and "nuvoro" fits 100vw on a single line. Without it, the word measures wider than the viewport — and because SplitText's mask wraps every char in an inline-block (each one a legal line-break point), the final "o" wraps onto a second line and the header (positioned top:25% with translate(-50%,-50%)`) gets cropped at the top of the viewport.
- All
img:width:100%; height:100%; object-fit:cover. - Type scale:
h14.25rem / weight 600 / letter-spacing -0.1rem / line-height 1.h22rem / weight 700 / letter-spacing -0.04rem / line-height 1.125 / antialiased.a, pdisplay block, no underline, 0.85rem / weight 600, color--fg. .nav:position: fixed; top:0; left:0; width:100vw; padding:1em; display:flex; gap:1em; z-index:2..nav .col:nth-child(1) { flex: 1 };.nav .col:nth-child(2) { flex: 2; display:flex; justify-content: space-between }. There are no CSS rules for.nav-itemsor.nav-item— leave them default block so the three nav links render as a vertical column list (do NOT adddisplay:flexorgapto.nav-items)..home:width:100%; height:100svh; overflow:hidden; background: var(--bg)..home .header: absolute,top:25%; left:50%; transform:translate(-50%,-50%); width:100%; itsh1isfont-size: 30.5vw; text-align:center(the wordmark fills the viewport width)..home .hero-img: absolute,left:50%; bottom:0; transform:translateX(-50%); width:95%; height:50vh; overflow:hidden..work:text-align:center; padding:15em 1em; display:flex; flex-direction:column; gap:2em; background:var(--bg)..work .projects:width:32%; margin:0 auto; column flex; gap:4em; project imagesaspect-ratio:4/5..studio, .contact:padding:15em 1em; display:flex; gap:1em; background:var(--bg); first.colflex 1, second.colflex 2 as a column withgap:2em..studio .about-img:aspect-ratio:5/7..contact:width:100vw; min-height:100svh..contact .socials: absolute,bottom:1.5em; display:flex; gap:1em.- SplitText helper classes:
.letter, .word, .line { position:relative; display:inline-block; will-change:transform }. .revealer:position:fixed; top:0; left:0; width:100vw; height:100svh; transform-origin:center top; background-color:var(--fg); pointer-events:none; z-index:2. It starts covering the whole viewport in dark and is scaled away by GSAP.- View Transitions CSS (required for the clip-path effect to work):
`` ::view-transition-old(root), ::view-transition-new(root) { animation: none !important; } ::view-transition-group(root) { z-index: auto !important; } ::view-transition-image-pair(root) { isolation: isolate; will-change: clip-path; z-index: 1; } ::view-transition-new(root) { z-index: 10000; animation: none !important; } ::view-transition-old(root) { z-index: 1; animation: none !important; } `` (Default snapshot cross-fades are disabled; the new snapshot sits on top at z-index 10000 so the custom clip-path animation controls the whole reveal.)
- Responsive at
max-width: 900px:.work .projectsbecomeswidth:90%;.studioand.contactswitch toflex-direction: column.
GSAP effect (be precise)
Setup
CustomEase.create("hop", "0.9, 0, 0.1, 1")— this "hop" ease drives the revealer wipe. The same bezier is reused ascubic-bezier(0.9, 0, 0.1, 1)in the WAAPI clip-path animation.- Start Lenis smooth scroll globally:
const lenis = new Lenis()plus the standardrequestAnimationFrameloop callinglenis.raf(time). - Build a
Mapofdata-path → section.viewand trackcurrentPath(starts at"/"). Also keep aMapof view → SplitText instance so a revisit canrevert()the previous split before re-splitting.
1. Revealer wipe (per view entrance)
For the entering view's .revealer:
gsap.set(revealer, { scaleY: 1 })(fully covering the viewport in--fgdark).gsap.to(revealer, { scaleY: 0, duration: 1.25, delay: 1, ease: "hop" })— becausetransform-originiscenter top, the dark panel collapses upward, unveiling the page beneath it after a 1s hold.
2. Masked SplitText heading reveal (per view entrance)
Each view splits its heading(s) with SplitText.create(elements, { type, <class option>, mask }) where mask equals the split type — this wraps each fragment in an overflow-clipping mask element so fragments slide in from below their own line box. Then:
gsap.set(fragments, { y: "110%" })(hidden below the mask).gsap.to(fragments, { y: "0%", duration: 1.5, stagger, delay, ease: "power4.out" }).
Per-view configuration table:
| view path | target | type | class option | mask | animated set | stagger | delay | |------------|--------|---------|---------------------------|---------|--------------|---------|-------| | / | h1 | chars | charsClass: "letter" | chars | .chars | 0.1 | 1.25 | | /work | h1 | words | wordsClass: "word" | words | .words | 0.25 | 1.75 | | /studio | h2 | lines | linesClass: "line" | lines | .lines | 0.1 | 1.5 | | /contact | h2 | lines | linesClass: "line" | lines | .lines | 0.1 | 1.75 |
Notes: the studio and contact views select ALL h2s in the view (headers, paragraph, emails), splitting them into lines together. Before splitting a view that was split previously, call revert() on its stored SplitText instance.
playView(view) = run the revealer wipe (1) + the SplitText reveal (2) together.
3. Clip-path page transition (nav click)
Each nav <a data-path> gets a click handler that preventDefault()s and calls navigate(path):
- If
path === currentPath, do nothing. - DOM swap function: hide the current view (
hidden = true), unhide the target,gsap.set(targetView.querySelector(".revealer"), { scaleY: 1 })so the incoming snapshot is captured with the dark overlay covering the viewport, then reset scroll withwindow.scrollTo(0, 0)andlenis.scrollTo(0, { immediate: true }). - If
document.startViewTransitionis unavailable, just swap, updatecurrentPath, andplayView(target)(graceful fallback). - Otherwise:
const transition = document.startViewTransition(() => { swap; currentPath = path; }), then intransition.ready.then(...)run BOTH: - The WAAPI clip-path animation on the new snapshot:
`` document.documentElement.animate( [ { clipPath: "polygon(25% 75%, 75% 75%, 75% 75%, 25% 75%)" }, { clipPath: "polygon(0% 100%, 100% 100%, 100% 0%, 0% 0%)" } ], { duration: 2000, easing: "cubic-bezier(0.9, 0, 0.1, 1)", pseudoElement: "::view-transition-new(root)" } ); `` The new page appears as a zero-height horizontal slit centered at 75% of the viewport height (from x=25% to x=75%) and expands to cover the full viewport.
playView(targetView)— so the revealer wipe (dark cover collapsing upward after its 1s delay) and the masked text reveal play *inside* the expanding clip.
4. Initial load
On document.fonts.ready, run playView on the home view: the dark revealer holds 1s, collapses upward over 1.25s with the "hop" ease, and the giant nuvoro wordmark letters rise out of their character masks (chars, stagger 0.1, delay 1.25, power4.out).
Assets / images
6 monochrome/black-and-white art-direction images (abstract sculptural photography works well — spheres, distorted portraits, smoke, rock textures):
- Hero — 1 wide image, displayed full-bleed at 95% viewport width × 50vh, anchored to the bottom of the home view (e.g. a grainy grey gradient orb on a near-white ground).
- Projects — 4 portrait images at 4:5 aspect ratio, stacked vertically in the work view (surreal B/W portrait, glossy stone spheres, rocket engines firing through fog, cluster of floating grey spheres — any cohesive monochrome set).
- Studio — 1 portrait image at 5:7 aspect ratio in the studio view's second column (e.g. a cracked metallic rock splitting apart).
Acceptance checks
- Wordmark: the home
h1"nuvoro" must render on a SINGLE line spanning roughly the full viewport width (~95–98vw), fully visible below the nav. If it wraps to a second line or overflows the viewport, the Inter font import is wrong (missing theopszaxis) — fix the@import, not the font-size. - Nav: the three nav links (work / studio / contact) stack vertically at the start of the nav's second column, with "toronto, ca" alone at the right edge. If they render in a horizontal row, a stray
display:flexwas added to.nav-items.
Behavior notes
- The whole document participates in the view transition (root snapshots); the CSS pseudo-element rules above are mandatory or the clip-path animation will not be visible.
- Clicking the currently-active nav link is a no-op.
- Scroll position resets to the top on every navigation (both native and Lenis, immediate).
- Lenis smooth scrolling applies to the taller inner views (work, studio, contact).
- In browsers without the View Transitions API, navigation still works: instant swap + revealer wipe + text reveal, no clip-path.
- Below 900px the project gallery widens to 90% and studio/contact stack into a single column.