Add these to your <head> tag. All scripts come from the official GSAP CDN (gsap.com/docs):
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.13.0/gsap.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.13.0/ScrollTrigger.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.13.0/Flip.min.js"></script>
All selectors use data attributes — no class names needed. Paste this into your stylesheet or Webstudio custom code:
[data-expand-backdrop],
[data-expand-img] {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100svh;
pointer-events: none;
overflow: hidden;
}
[data-expand-bg],
[data-expand-container] {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 50%;
min-width: 720px;
aspect-ratio: 16/9;
will-change: width, height;
}
[data-expand-bg] {
background-color: var(--base-100);
pointer-events: none;
z-index: 0;
}
[data-expand-container] {
display: flex;
justify-content: space-between;
align-items: flex-start;
z-index: 2;
}
[data-expand-logo] {
position: absolute;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 100%;
padding: 2.5rem;
pointer-events: all;
z-index: 2;
}
[data-expand-logo].is-pinned {
bottom: unset;
top: -0.25rem;
}
[data-expand-logo] img {
object-fit: contain;
}
[data-expand-links] {
position: relative;
width: 50%;
display: flex;
justify-content: space-between;
}
[data-expand-links]:nth-child(1) {
padding: 2.5rem 5rem 0 2.5rem;
}
[data-expand-links]:nth-child(2) {
padding: 2.5rem 2.5rem 0 5rem;
}
Add data attributes to your elements. The JavaScript discovers everything by attribute — no IDs or classes required:
<!-- Fixed backdrop layer -->
<div data-expand-backdrop>
<div data-expand-img>
<img src="your-background.jpg" alt="" />
</div>
<div data-expand-bg></div>
</div>
<!-- Navbar items -->
<div data-expand-container>
<div data-expand-links>
<a href="#">Index</a>
<a href="#">Studio</a>
</div>
<div data-expand-links>
<a href="#">Archive</a>
<a href="#">Connect</a>
</div>
<div data-expand-logo data-logo-width="250">
<a href="#"><img src="your-logo.svg" alt="" /></a>
</div>
</div>
<!-- Page content (must come after so scroll works) -->
<section class="hero" style="margin-top: 200svh;">
<h1>Your heading here</h1>
</section>
Place before closing </body> tag. All element selection uses
querySelector('[data-*]') — fully attribute-driven:
gsap.registerPlugin(ScrollTrigger, Flip);
const initExpandAnimation = () => {
const bg = document.querySelector("[data-expand-bg]");
const container = document.querySelector("[data-expand-container]");
const links = document.querySelectorAll("[data-expand-links]");
const logo = document.querySelector("[data-expand-logo]");
if (!bg || !container || !logo) return;
const isDesktop = window.innerWidth >= 720;
const logoWidth = parseFloat(logo.dataset.logoWidth || "250");
if (!isDesktop) {
logo.classList.add("is-pinned");
gsap.set(logo, { width: logoWidth });
gsap.set([bg, container], { width: "100%", height: "100vh" });
return;
}
const vw = window.innerWidth;
const vh = window.innerHeight;
const initialWidth = bg.offsetWidth;
const initialHeight = bg.offsetHeight;
const initialLinksWidths = Array.from(links).map((l) => l.offsetWidth);
const state = Flip.getState(logo);
logo.classList.add("is-pinned");
gsap.set(logo, { width: logoWidth });
const flip = Flip.from(state, { duration: 1, ease: "none", paused: true });
ScrollTrigger.create({
trigger: "[data-expand-backdrop]",
start: "top top",
end: `+=${vh}px`,
scrub: 1,
onUpdate: (self) => {
const p = self.progress;
gsap.set([bg, container], {
width: gsap.utils.interpolate(initialWidth, vw, p),
height: gsap.utils.interpolate(initialHeight, vh, p),
});
links.forEach((link, i) => {
gsap.set(link, {
width: gsap.utils.interpolate(
link.offsetWidth,
initialLinksWidths[i],
p,
),
});
});
flip.progress(p);
},
});
};
document.addEventListener("DOMContentLoaded", () => {
initExpandAnimation();
let timer;
window.addEventListener("resize", () => {
clearTimeout(timer);
timer = setTimeout(() => {
ScrollTrigger.getAll().forEach((t) => t.kill());
const bg = document.querySelector("[data-expand-bg]");
const container = document.querySelector("[data-expand-container]");
const links = document.querySelectorAll("[data-expand-links]");
const logo = document.querySelector("[data-expand-logo]");
gsap.set([bg, container, logo, ...links], { clearProps: "all" });
logo.classList.remove("is-pinned");
initExpandAnimation();
}, 250);
});
});
| Attribute | Element | Description |
|---|---|---|
data-expand-backdrop |
Outer wrapper | Fixed fullscreen layer, acts as the ScrollTrigger target |
data-expand-img |
Image wrapper | Fixed fullscreen container for the background image behind the navbar |
data-expand-bg |
Background div | The solid-color panel that expands from 50% width to full viewport |
data-expand-container |
Nav items wrapper | Holds the links and logo, expands in sync with the background |
data-expand-links |
Link groups | Flexbox containers for nav links (use two: left group + right group) |
data-expand-logo |
Logo wrapper | Repositions from bottom-center to top via GSAP Flip on scroll |
data-logo-width |
Logo wrapper |
Logo width (px) when pinned at top. Default: 250
|
@media (max-width: 720px) {
[data-expand-bg],
[data-expand-container] {
min-width: 100%;
}
[data-expand-container],
[data-expand-links] {
flex-direction: column;
justify-content: flex-start;
align-items: flex-end;
gap: 0.5rem;
}
[data-expand-container] {
padding: 2rem;
}
[data-expand-links]:nth-child(1),
[data-expand-links]:nth-child(2) {
padding: 0;
}
[data-expand-logo],
[data-expand-logo].is-pinned {
left: 0;
transform: translateX(0);
}
}
The page starts with the navbar as a centered 16:9 box overlaying a fullscreen background image. As the user scrolls, three things happen simultaneously:
1. Background expands — The
data-expand-bg panel interpolates from its initial size
(50% width, 16:9 ratio) to the full viewport dimensions using
gsap.utils.interpolate().
2. Container follows — The
data-expand-container expands in lockstep so the nav
links stay positioned correctly.
3. Logo repositions — GSAP
Flip captures the logo's initial state (bottom-center),
then the .is-pinned class moves it to the top. The Flip
animation plays in sync with scroll progress, creating a smooth
positional transition.
On resize, all ScrollTriggers are killed, inline styles cleared, and the animation re-initializes to recalculate dimensions.