MUST

·

Nav · direction

Scroll-aware navigation

A fixed nav costs a phone user 15% of their screen on every single scroll. A nav that leaves when you are reading and comes back the moment you reach for it costs nothing and feels like the site is paying attention. Three separate behaviours share one bar: a threshold state (background appears once you are off the hero), a direction state (self.direction from a triggerless ScrollTrigger), and a menu timeline built once and replayed. Keeping them independent is what stops the whole thing turning into a pile of conflicting tweens.

Open the live demo →

How to build

Panel + script

Free plan gets you part of the way

The panel covers some of this. The rest needs the script, which needs a paid plan — build the panel half now, come back for the rest.

How the two routes work →

Plugins

ScrollTrigger · quickTo

Build time

45 min including the overlay.

No-code?

Webflow ships a “scroll into view” trigger that can do the background swap, and the menu opens fine with a Page Load / Click interaction. Direction-aware hiding is not in the panel — that needs self.direction.

01

Webflow structure

element · class · attribute

<b>Navbar</b> <i>nav_component</i> ← position: fixed, top 0, z-index 60 <b>Div</b> <i>nav_bg</i> ← absolute inset 0, z-index -1, blurred <b>Link Block</b> <i>nav_logo</i> <b>Div</b> <i>nav_links</i> <b>Link</b> <i>nav_link</i> ×4 <b>Div</b> <i>nav_spacer</i> ← flex: 1 <b>Link Block</b> <i>nav_cta</i> <b>Div</b> <i>nav_burger</i> <u>data-menu-toggle</u> <b>Div</b> <i>nav_burger-line</i> ×2 <b>Div</b> <i>menu_overlay</i> <u>data-menu</u> ← position: fixed, inset 0, z-index 55 <b>Div</b> <i>menu_list</i> <b>Div</b> <i>menu_line</i> ← overflow: hidden (the mask) <b>Link</b> <i>menu_link</i> <b>Div</b> <i>menu_meta</i>

02

Styles to set in the Designer

nav_component | <code>position: fixed; top: 0; width: 100%; z-index: 60</code>. Keep the z-index above the overlay so the close button stays reachable. nav_bg | Absolute inset 0, <code>z-index: -1</code>, <code>background: rgba(12,12,14,.72)</code>, <code>backdrop-filter: blur(16px)</code>, <code>opacity: 0</code>. A separate layer so you fade the background without fading the links. menu_overlay | <code>position: fixed; inset: 0; visibility: hidden;</code> and <code>clip-path: inset(0% 0% 100% 0%)</code>. Clip-path, not height — height animations trigger layout on every frame. menu_line | <code>overflow: hidden</code>. This is what makes the links rise out of nothing instead of sliding across. nav_link::after | 1px absolute rule, <code>transform: scaleX(0)</code>, origin right. On hover origin left, scaleX 1 — the underline leaves the way it came. Pure CSS, no JS needed.

03

Building it with the Interactions panel

no code, free plan
  1. Select nav_bg → Interactions → trigger Scroll into view with a small offset (e.g. after 60px) → animate Opacity 0 → 100% and the nav's padding, for the threshold state.
  2. Build the menu as a separate Click interaction: trigger on nav_burger → target menu_overlay → animate Clip from 0% 0% 100% 0% to 0% 0% 0% 0%, then stagger menu_link in with Move Y 110% → 0%.
  3. Set the burger's interaction to Mouse click (toggle) so the same trigger closes the menu on a second click.

What the panel still can’t do. Only custom code can close these gaps — there is no panel setting for any of them.

  • Hiding the nav on scroll-down and returning it on scroll-up has no panel trigger — there's no exposed equivalent of self.direction, so a panel-only nav can react to "has scrolled" but not to "which way".

See “The script” below for the exact code.

04

Custom attributes

Element settings → Custom attributes

nav_burger | data-menu-toggle | (empty) | Anything with this attribute toggles the overlay — burger, close button, or a link inside the menu. menu_overlay | data-menu | (empty) | The script writes <code>data-open="true"</code> here; the nav reads it so it never hides behind an open menu.

05

CSS

Page settings → Inside <head> tag

Inside <head> tag

Copy

06

The script

Page settings → Before </body> tag

Before </body> tag

Copy

<!-- Page settings → Before </body> tag --> <script> document.addEventListener("DOMContentLoaded", () => { gsap.registerPlugin(ScrollTrigger); const nav = document.querySelector(".nav_component"); const bg = nav.querySelector(".nav_bg"); const menu = document.querySelector("[data-menu]"); // ── 1 · threshold state ──────────────────────────────────────────── // A ScrollTrigger with no trigger measures the page itself, which is // exactly what you want for "am I off the hero yet". ScrollTrigger.create({ start: "top -60", end: 99999, onToggle: self => { gsap.to(bg, { opacity: self.isActive ? 1 : 0, duration: 0.4 }); gsap.to(nav, { paddingTop: self.isActive ? 12 : 22, paddingBottom: self.isActive ? 12 : 22, duration: 0.45, ease: "power3.out" }); } }); // ── 2 · direction state ──────────────────────────────────────────── const show = gsap.quickTo(nav, "yPercent", { duration: 0.45, ease: "power3.out" }); ScrollTrigger.create({ start: "top -200", // no hiding until we are clear of the hero end: 99999, onUpdate: self => { if (menu.dataset.open === "true") return; // never hide under an open menu show(self.direction === 1 ? -100 : 0); // 1 = down, -1 = up }, onLeaveBack: () => show(0) }); // ── 3 · the overlay: built once, replayed forever ────────────────── const tl = gsap.timeline({ paused: true }) .set(menu, { visibility: "visible" }) .to(menu, { clipPath: "inset(0% 0% 0% 0%)", duration: 0.75, ease: "expo.inOut" }) .from(menu.querySelectorAll(".menu_link"), { yPercent: 110, duration: 0.75, ease: "expo.out", stagger: 0.06 }, "-=0.4") .from(menu.querySelectorAll(".menu_meta > *"), { opacity: 0, duration: 0.5, stagger: 0.05 }, "-=0.35"); let open = false; const toggle = () => { open = !open; menu.dataset.open = String(open); document.body.style.overflow = open ? "hidden" : ""; // Closing faster than opening is the detail nobody notices and everybody feels. open ? tl.timeScale(1).play() : tl.timeScale(1.6).reverse(); }; document.querySelectorAll("[data-menu-toggle]").forEach(b => b.addEventListener("click", toggle)); menu.querySelectorAll("a").forEach(a => a.addEventListener("click", () => open && toggle())); document.addEventListener("keydown", e => { if (e.key === "Escape" && open) toggle(); }); }); </script>

07

Gotchas that will cost you an hour

  1. Build the timeline once, outside the click handler. Building it inside means a new timeline per click; after ten opens you have ten timelines racing on the same nodes and the menu starts flickering.
  2. Closing should be faster than opening. timeScale(1.6) on reverse. Users have already decided when they close — matching the open duration feels like the site is arguing.
  3. Animate clip-path, never height. Height forces layout on every frame and drops the menu to about 20fps on a mid-range Android.
  4. Lock the body scroll while open, and put it back. Forgetting the reset is the single most common bug in this pattern — the site is dead until refresh. If you use Lenis, call lenis.stop() / lenis.start() instead of touching overflow.
  5. Do not hide the nav above 200px of scroll. Hiding it inside the hero looks like a rendering glitch. The threshold is doing real work.
  6. Escape must close it, and the burger needs aria-expanded. Webflow's certification rubric checks this, and it is two lines.
08

Push it further

  • Add an active-section indicator: one ScrollTrigger per section that moves a small underline with Flip between the nav links.
  • Feed self.getVelocity() into the nav's hide duration so a hard flick hides it faster than a slow drag.
  • Give the overlay a staggered clip-path per column instead of one full-width wipe — the same timeline, four elements, and it reads as a much more expensive site.

Webflow Motion Lab — GSAP 3.15, all plugins free.

9

/ 30