Index Studio
Archive Connect
NAKED CITY FILMS

Designing movement beyond fixed frames and rigid form

The frame dissolves, but the movement continues forward

1. CDN Scripts

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>

2. Required CSS

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;
}

3. HTML Structure

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>

4. JavaScript

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);
  });
});

5. Data Attributes Reference

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

6. Mobile Responsive CSS

@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);
  }
}

7. How It Works

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.