All components

NakedCity Films Navbar Scroll Animation

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

Open live demo ↗ Raw prompt (.md)

What it does

A fixed, centered 16:9 navbar window (cream panel with nav links over a full-viewport fixed image) expands from 50% width to fullscreen as you scroll the first viewport, scrubbed via ScrollTrigger onUpdate with gsap.utils.interpolate while a paused Flip timeline flies the oversized logo from bottom-center to a pinned top position. Lenis drives the smooth scrolling.

How it's built

Categoryscroll
Techgsap, lenis
GSAP pluginsScrollTrigger, Flip
Complexitypage
Performance costmedium
Mobile-safeyes
Scrollhijacks scrolling

navbar scroll scrub flip expand lenis fixed logo hero

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

Studio Navbar — Scroll-Expanding 16:9 Window with Flip Logo Fly

Goal

Build a fixed, centered 16:9 "navbar window" — a cream panel carrying two pairs of nav links and an oversized studio logo — floating over a full-viewport fixed cinematic backdrop image. The star effect: as you scroll the first viewport height, the cream window expands from ~50% width up to fullscreen (scrubbed frame-by-frame via ScrollTrigger.onUpdate + gsap.utils.interpolate), the nav links hold their pixel width near the corners instead of spreading, and simultaneously a paused GSAP Flip animation flies the big bottom-centered logo up to a small pinned position at the very top, shrinking it as it goes. Once the window has filled the screen, further scrolling reveals a plain hero heading and an about section. Smooth scroll via Lenis. Desktop-only for the expand effect; mobile gets a static full-screen fallback.

Tech

Vanilla HTML/CSS/JS with ES module imports, fresh Vite project. Install and import from npm:

  • gsap (3.x) plus the plugins ScrollTrigger and Flip.
  • lenis — smooth scroll.
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import { Flip } from "gsap/all";
import Lenis from "lenis";

gsap.registerPlugin(ScrollTrigger, Flip);

Everything runs from document.addEventListener("DOMContentLoaded", …).

Layout / HTML

Two fixed navbar layers (backdrop + items) plus two normal-flow sections. All class names are load-bearing — the JS and CSS query them.

<!-- fixed backdrop: full-viewport image + centered cream panel -->
<div class="navbar-backdrop">
  <div class="navbar-img">
    <img src="/path/to/backdrop.jpg" alt="" />
  </div>
  <div class="navbar-background"></div>
</div>

<!-- fixed 16:9 window content: two link groups + oversized logo -->
<div class="navbar-items">
  <div class="navbar-links">
    <a href="#">Index</a>
    <a href="#">Studio</a>
  </div>
  <div class="navbar-links">
    <a href="#">Archive</a>
    <a href="#">Connect</a>
  </div>

  <div class="navbar-logo">
    <a href="#"><img src="/path/to/logo.svg" alt="" /></a>
  </div>
</div>

<section class="hero">
  <h1>Designing movement beyond fixed frames and rigid form</h1>
</section>

<section class="about">
  <h1>The frame dissolves, but the movement continues forward</h1>
</section>

<script type="module" src="./script.js"></script>

Notes:

  • Exactly two .navbar-links groups (each holds two <a>). Group 1 = Index / Studio, group 2 = Archive / Connect.
  • .navbar-logo is a single oversized element pinned to the bottom of the navbar window in the markup; the JS re-pins it to the top and shrinks it via Flip. There is no brand — treat the logo as a neutral studio wordmark (see Assets).
  • Keep the hero/about headings verbatim (neutral editorial copy, no client names).

Styling

Fonts (Google Fonts): Barlow Condensed (headings) and Host Grotesk (nav links).

@import url("https://fonts.googleapis.com/css2?family=Barlow+Condensed:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&family=Host+Grotesk:ital,wght@0,300..800;1,300..800&display=swap");

Palette (CSS variables — only two tokens):

--base-100: #f9f4eb;  /* warm cream — page bg, the navbar panel, link/heading text color base */
--base-200: #141414;  /* near-black — text color (links + headings) */

Reset & globals:

  • * { margin:0; padding:0; box-sizing:border-box; }
  • body { background-color: var(--base-100); color: var(--base-200); }
  • img { width:100%; height:100%; object-fit:cover; }
  • h1 { text-transform:uppercase; font-family:"Barlow Condensed",sans-serif; font-size:clamp(3rem, 5vw, 7rem); font-weight:900; line-height:0.8; }
  • a { text-decoration:none; color:var(--base-200); font-family:"Host Grotesk"; font-size:1.125rem; font-weight:450; line-height:0.9; }

The fixed backdrop layers:

  • .navbar-backdrop, .navbar-img { position:fixed; top:0; left:0; width:100%; height:100svh; pointer-events:none; overflow:hidden; }.navbar-img holds the full-viewport backdrop image.

The centered 16:9 window — two co-located, identically-sized layers (.navbar-background behind, .navbar-items in front):

.navbar-background,
.navbar-items {
  position: fixed;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  width: 50%;
  min-width: 720px;      /* window never narrower than 720px */
  aspect-ratio: 16 / 9;  /* height derived from width */
  will-change: width, height;
}
.navbar-background { background-color: var(--base-100); pointer-events:none; z-index:0; } /* the opaque cream panel */
.navbar-items     { display:flex; justify-content:space-between; align-items:flex-start; z-index:2; } /* link groups sit along the top edge */

Stacking (paint order matters): .navbar-background z-index:0 (paints over the fixed image), section z-index:1, .navbar-items + .navbar-logo z-index:2 (top). So on load you see: full-viewport image, a centered opaque cream 16:9 card over it, nav links along the card's top and a huge logo along its bottom.

Nav link groups:

.navbar-links { position:relative; width:50%; display:flex; justify-content:space-between; }
.navbar-links:nth-child(1) { padding: 2.5rem 5rem 0 2.5rem; }  /* left group */
.navbar-links:nth-child(2) { padding: 2.5rem 2.5rem 0 5rem; }  /* right group */

The logo (default = bottom of the window, full width; pinned = top, tiny):

.navbar-logo {
  position:absolute; bottom:0; left:50%; transform:translateX(-50%);
  width:100%; padding:2.5rem; pointer-events:all; z-index:2;
}
.navbar-logo.navbar-logo-pinned { bottom:unset; top:-0.25rem; } /* JS toggles this class */
.navbar-logo img { object-fit:contain; }

Sections:

section { position:relative; width:100%; display:flex; justify-content:center; align-items:center;
          text-align:center; overflow:hidden; z-index:1; }
.hero  { padding:2.5rem 0; margin-top:200svh; } /* 2-viewport scroll runway ABOVE the hero */
.about { height:100svh; }
.hero h1, .about h1 { width:50%; }

The margin-top:200svh on .hero is the scroll runway that lets you scroll while the fixed navbar animates; keep it.

GSAP effect (be exhaustive)

Three coupled pieces: (A) Lenis↔GSAP wiring, (B) a paused Flip animation of the logo, (C) a single ScrollTrigger whose onUpdate manually drives the window expansion, link counter-sizing, and the Flip's progress. There is no timeline — the whole thing is ScrollTrigger.create({ onUpdate }) plus gsap.utils.interpolate.

(A) Smooth scroll wiring
const lenis = new Lenis();
lenis.on("scroll", ScrollTrigger.update);
gsap.ticker.add((time) => lenis.raf(time * 1000));
gsap.ticker.lagSmoothing(0);
The init function (initNavbarAnimations)

Grab the elements:

const navbarBg    = document.querySelector(".navbar-background");
const navbarItems = document.querySelector(".navbar-items");
const navbarLinks = document.querySelectorAll(".navbar-links");   // 2 groups
const navbarLogo  = document.querySelector(".navbar-logo");

Mobile fallback (window.innerWidth < 720) — no scroll effect, just snap to fullscreen:

navbarLogo.classList.add("navbar-logo-pinned");
gsap.set(navbarLogo, { width: 250 });
gsap.set([navbarBg, navbarItems], { width: "100%", height: "100vh" });
return;

Desktop (window.innerWidth >= 720) — measure, set up Flip, create the ScrollTrigger:

  1. Measure initial geometry (the small 16:9 window and each link's rendered width):
const viewportWidth  = window.innerWidth;
const viewportHeight = window.innerHeight;
const initialWidth   = navbarBg.offsetWidth;   // ~50% vw (>=720px)
const initialHeight  = navbarBg.offsetHeight;  // 16:9 of that
const initialLinksWidths = Array.from(navbarLinks).map((link) => link.offsetWidth);
  1. (B) Build the paused Flip for the logo — capture its current (bottom, full-width) layout, re-pin it to the top and shrink it to 250px, then create a paused Flip that will smoothly interpolate between the two layouts:
const state = Flip.getState(navbarLogo);          // record bottom/full-width position
navbarLogo.classList.add("navbar-logo-pinned");   // new layout: top:-0.25rem
gsap.set(navbarLogo, { width: 250 });             // new layout: 250px wide
const flip = Flip.from(state, { duration: 1, ease: "none", paused: true });

So the logo's target end-state is baked in immediately (top, 250px), and flip.progress(p) scrubs it back-and-forth between the recorded bottom/full-width start and this top/250px end. ease:"none" → linear, so it tracks scroll exactly.

  1. (C) The single scrubbed ScrollTrigger — no tween/timeline, all work happens in onUpdate:
ScrollTrigger.create({
  trigger: ".navbar-backdrop",
  start: "top top",
  end: `+=${viewportHeight}px`,   // the effect completes over exactly one viewport height
  scrub: 1,                        // 1s smoothing between scroll position and progress
  onUpdate: (self) => {
    const p = self.progress;       // 0 → 1

    // window expands: small 16:9 → full viewport
    gsap.set([navbarBg, navbarItems], {
      width:  gsap.utils.interpolate(initialWidth,  viewportWidth,  p),
      height: gsap.utils.interpolate(initialHeight, viewportHeight, p),
    });

    // links counter-size: interpolate CURRENT width → the small initial width,
    // so they stay a fixed pixel size near the corners instead of spreading with the growing window
    navbarLinks.forEach((link, i) => {
      gsap.set(link, {
        width: gsap.utils.interpolate(link.offsetWidth, initialLinksWidths[i], p),
      });
    });

    flip.progress(p);              // scrub the logo fly-up in lockstep
  },
});

Behavior of each animated quantity across p 0 → 1:

  • .navbar-background + .navbar-items width/height: interpolate from initialWidth/Height (the ~50%-vw 16:9 card) to full viewportWidth/viewportHeight. The cream panel grows outward from center (it stays translate(-50%,-50%) centered) until it fills the screen and hides the backdrop image. aspect-ratio is overridden by the explicit width+height here.
  • Each .navbar-links width: interpolate from the link's *current* offsetWidth (re-read every frame) toward its *initial* small offsetWidth. Net effect: as the flex container grows, this holds the link groups to roughly their original pixel width so they park near the left/right edges rather than stretching apart.
  • flip.progress(p): drives the Flip so the logo travels from bottom-center, full window-width to top (top:-0.25rem), 250px wide, still horizontally centered, shrinking as it rises. The inner <img> is object-fit:contain, so the wordmark scales down cleanly.

All three are locked to the same p, so scrolling down plays them forward together and scrolling up reverses them. scrub:1 adds a 1-second inertial catch-up.

Resize handling (rebuild on resize, debounced)

On window resize, debounce 250ms, then: kill every ScrollTrigger, gsap.set([...all navbar els], { clearProps: "all" }), remove navbar-logo-pinned, and call initNavbarAnimations() again so all measurements (and the desktop/mobile branch) recompute.

let timer;
window.addEventListener("resize", () => {
  clearTimeout(timer);
  timer = setTimeout(() => {
    ScrollTrigger.getAll().forEach((t) => t.kill());
    gsap.set([navbarBg, navbarItems, navbarLogo, ...navbarLinks], { clearProps: "all" });
    navbarLogo.classList.remove("navbar-logo-pinned");
    initNavbarAnimations();
  }, 250);
});

No SplitText, no CustomEase, no manual lerp/rAF loop (Lenis owns the rAF), no Three.js. The only easing is Flip's linear ease:"none"; everything else is direct interpolate.

Assets / images

  • 1 full-viewport fixed backdrop image, object-fit:cover, filling the whole 100svh viewport (landscape ~2:1, e.g. 1440×720). A moody, cinematic, stylized city scene reads best — here a rain-slicked tree-lined avenue at night viewed head-on down its vanishing-point center: wet cobblestone road reflecting light, rounded futuristic cars with glowing headlight rings driving toward the camera, glowing streetlamps and neon-lit storefronts along both sides, scattered silhouetted pedestrians, and a hazy skyline of towers in the distance. Dominated by saturated neon purple/magenta and pink tones with warm amber lamp glints against dark shadows. It sits behind and around the cream window and is progressively covered as the window expands. No brands or logos in it.
  • 1 wordmark logo, SVG, dark near-black (#141414-ish) on transparency, a wide horizontal single-word wordmark (~6.9:1 aspect, e.g. 4938×716 viewBox), rendered object-fit:contain. Use a neutral fictional studio name — do not use any real client brand.

Behavior notes

  • Desktop-only expand effect. Below 720px viewport width the JS snaps the window to fullscreen and pins the logo immediately (no ScrollTrigger). At max-width:720px the CSS also reflows: window min-width:100%, link groups stack vertically and right-aligned, logo left-anchored, and .hero drops its margin-top to 0 with height:100svh; hero/about h1 go full width with padding.
  • Heights use svh (100svh, margin-top:200svh) so mobile browser chrome doesn't break the layout.
  • The effect is fully scroll-scrubbed (scrub:1) over one viewport height; parking the scroll freezes it mid-expand, scrolling back up reverses it.
  • Keep will-change:width,height on the window layers and pointer-events:none on the backdrop / cream panel so only the links and logo are interactive.
  • No reduced-motion guard in the original.