Plan 01
01 Plan
Design 02
02 Design
Develop 03
03 Develop

Keep scrolling — it gets good

Stuff I make so you don't have to

Plan 01
01 Plan
Plan 01

Discovery

Audit

User Flow

Site Map

Personas

Strategy

01 Plan
Design 02
02 Design
Design 02

Wireframes

UI Kits

Prototypes

Visual Style

Interaction

Design QA

02 Design
Develop 03
03 Develop
Develop 03

HTML/CSS/JS

CMS Build

GSAP Motion

Responsive

Optimization

Launch

03 Develop
Plan 01
01 Plan
Plan 01

Discovery

Audit

User Flow

Site Map

Personas

Strategy

01 Plan
Design 02
02 Design
Design 02

Wireframes

UI Kits

Prototypes

Visual Style

Interaction

Design QA

02 Design
Develop 03
03 Develop
Develop 03

HTML/CSS/JS

CMS Build

GSAP Motion

Responsive

Optimization

Launch

03 Develop

The story's not over yet

1. CDN Scripts

Add these to your <head> or before closing </body> tag. All from gsap.com:

<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/ScrollTrigger.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/lenis@1.3.17/dist/lenis.min.js"></script>

2. Data Attributes

The script uses data-scroll-anim on sections and data-hero-card / data-flip-card on cards to select and animate elements.

Attribute Value Description
data-scroll-anim hero-scatter Hero section — cards scatter on scroll
data-scroll-anim services-pin Services section — pinned with header reveal
data-scroll-anim cards-entrance Cards layer — entrance, spread, converge, flip
data-hero-card 1 / 2 / 3 Hero card index (left, center, right)
data-flip-card 1 / 2 / 3 Flip card index (Plan, Design, Develop)

3. Required CSS

:root {
  --dark: #000;
  --light: #f9f4eb;
  --light2: #f0ece5;
  --accent-1: #e5d9f6;
  --accent-2: #ffd2f3;
  --accent-3: #fcdca6;
}

section {
  position: relative;
  width: 100vw;
  height: 100svh;
  padding: 2rem;
  overflow: hidden;
}

.hero-cards {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  width: 35%;
  display: flex;
  justify-content: center;
  gap: 1rem;
}

.hero-cards .card {
  flex: 1;
  aspect-ratio: 5/7;
  padding: 0.75rem;
  border-radius: 0.5rem;
  display: flex;
  flex-direction: column;
  justify-content: space-between;
}

.services-header {
  position: relative;
  width: 100%;
  text-align: center;
  transform: translateY(400%);
  will-change: transform;
}

.cards {
  position: fixed;
  top: 0; left: 0;
  width: 100vw;
  height: 100svh;
  display: flex;
  justify-content: center;
  z-index: -1;
  background-color: var(--light);
}

.cards .cards-container .card { opacity: 0; }

.cards [data-flip-card="1"] {
  transform: translateX(100%) translateY(-100%) rotate(-5deg) scale(0.25);
}
.cards [data-flip-card="2"] {
  transform: translateX(0%) translateY(-100%) rotate(0deg) scale(0.25);
}
.cards [data-flip-card="3"] {
  transform: translateX(-100%) translateY(-100%) rotate(5deg) scale(0.25);
}

.flip-card-inner {
  position: relative;
  width: 100%; height: 100%;
  transform-style: preserve-3d;
}

.flip-card-front,
.flip-card-back {
  position: absolute;
  width: 100%; height: 100%;
  border-radius: 1rem;
  backface-visibility: hidden;
}

.flip-card-back {
  transform: rotateY(180deg);
}

@keyframes floating {
  0%   { transform: translate(-50%, -50%); }
  50%  { transform: translate(-50%, -55%); }
  100% { transform: translate(-50%, -50%); }
}

4. JavaScript

gsap.registerPlugin(ScrollTrigger);

document.addEventListener("DOMContentLoaded", () => {
  // --- Lenis smooth scroll ---
  const lenis = new Lenis();
  lenis.on("scroll", ScrollTrigger.update);
  gsap.ticker.add((time) => lenis.raf(time * 1000));
  gsap.ticker.lagSmoothing(0);

  const smoothStep = (p) => p * p * (3 - 2 * p);

  if (window.innerWidth > 1000) {

    // ========================================
    // 1. HERO SCATTER — [data-scroll-anim="hero-scatter"]
    // ========================================
    const heroSection = document.querySelector('[data-scroll-anim="hero-scatter"]');
    if (heroSection) {
      ScrollTrigger.create({
        trigger: heroSection,
        start: "top top",
        end: "75% top",
        scrub: 1,
        onUpdate: (self) => {
          const progress = self.progress;

          gsap.set(heroSection.querySelector(".hero-cards"), {
            opacity: gsap.utils.interpolate(1, 0.5, smoothStep(progress)),
          });

          heroSection.querySelectorAll("[data-hero-card]").forEach((card) => {
            const index = parseInt(card.dataset.heroCard) - 1;
            const delay = index * 0.9;
            const cardProgress = gsap.utils.clamp(
              0, 1,
              (progress - delay * 0.1) / (1 - delay * 0.1)
            );

            const y = gsap.utils.interpolate("0%", "350%", smoothStep(cardProgress));
            const scale = gsap.utils.interpolate(1, 0.75, smoothStep(cardProgress));

            let x = "0%";
            let rotation = 0;
            if (index === 0) {
              x = gsap.utils.interpolate("0%", "90%", smoothStep(cardProgress));
              rotation = gsap.utils.interpolate(0, -15, smoothStep(cardProgress));
            } else if (index === 2) {
              x = gsap.utils.interpolate("0%", "-90%", smoothStep(cardProgress));
              rotation = gsap.utils.interpolate(0, 15, smoothStep(cardProgress));
            }

            gsap.set(card, { y, x, rotation, scale });
          });
        },
      });
    }

    // ========================================
    // 2. SERVICES PIN — [data-scroll-anim="services-pin"]
    // ========================================
    const servicesSection = document.querySelector('[data-scroll-anim="services-pin"]');
    if (servicesSection) {
      ScrollTrigger.create({
        trigger: servicesSection,
        start: "top top",
        end: `+=${window.innerHeight * 4}px`,
        pin: servicesSection,
        pinSpacing: true,
      });

      // Cards layer: fixed → absolute positioning
      const cardsLayer = document.querySelector('[data-scroll-anim="cards-entrance"]');
      ScrollTrigger.create({
        trigger: servicesSection,
        start: "top top",
        end: `+=${window.innerHeight * 4}px`,
        onLeave: () => {
          const rect = servicesSection.getBoundingClientRect();
          gsap.set(cardsLayer, {
            position: "absolute",
            top: window.pageYOffset + rect.top,
            left: 0, width: "100vw", height: "100vh",
          });
        },
        onEnterBack: () => {
          gsap.set(cardsLayer, {
            position: "fixed",
            top: 0, left: 0, width: "100vw", height: "100vh",
          });
        },
      });

      // ========================================
      // 3. CARDS ENTRANCE — [data-scroll-anim="cards-entrance"]
      // ========================================
      ScrollTrigger.create({
        trigger: servicesSection,
        start: "top bottom",
        end: `+=${window.innerHeight * 4}`,
        scrub: 1,
        onUpdate: (self) => {
          const progress = self.progress;

          // Services header slide-up
          const headerProgress = gsap.utils.clamp(0, 1, progress / 0.9);
          gsap.set(servicesSection.querySelector(".services-header"), {
            y: gsap.utils.interpolate("400%", "0%", smoothStep(headerProgress)),
          });

          // Each flip card: enter → spread → converge → flip
          document.querySelectorAll("[data-flip-card]").forEach((card) => {
            const index = parseInt(card.dataset.flipCard) - 1;
            const delay = index * 0.5;
            const cardProgress = gsap.utils.clamp(
              0, 1,
              (progress - delay * 0.1) / (0.9 - delay * 0.1)
            );

            const innerCard = card.querySelector(".flip-card-inner");

            // --- Y position (3 phases) ---
            let y;
            if (cardProgress < 0.4) {
              y = gsap.utils.interpolate("-100%", "50%", smoothStep(cardProgress / 0.4));
            } else if (cardProgress < 0.6) {
              y = gsap.utils.interpolate("50%", "0%", smoothStep((cardProgress - 0.4) / 0.2));
            } else {
              y = "0%";
            }

            // --- Scale (3 phases) ---
            let scale;
            if (cardProgress < 0.4) {
              scale = gsap.utils.interpolate(0.25, 0.75, smoothStep(cardProgress / 0.4));
            } else if (cardProgress < 0.6) {
              scale = gsap.utils.interpolate(0.75, 1, smoothStep((cardProgress - 0.4) / 0.2));
            } else {
              scale = 1;
            }

            // --- Opacity ---
            let opacity;
            if (cardProgress < 0.2) {
              opacity = smoothStep(cardProgress / 0.2);
            } else {
              opacity = 1;
            }

            // --- X, rotate, flip (2 phases) ---
            let x, rotate, rotationY;
            if (cardProgress < 0.6) {
              x = index === 0 ? "100%" : index === 1 ? "0%" : "-100%";
              rotate = index === 0 ? -5 : index === 1 ? 0 : 5;
              rotationY = 0;
            } else if (cardProgress < 1) {
              const np = smoothStep((cardProgress - 0.6) / 0.4);
              x = gsap.utils.interpolate(
                index === 0 ? "100%" : index === 1 ? "0%" : "-100%",
                "0%", np
              );
              rotate = gsap.utils.interpolate(
                index === 0 ? -5 : index === 1 ? 0 : 5,
                0, np
              );
              rotationY = np * 180;
            } else {
              x = "0%"; rotate = 0; rotationY = 180;
            }

            gsap.set(card, { opacity, y, x, rotate, scale });
            gsap.set(innerCard, { rotationY });
          });
        },
      });
    }
  }
});

5. HTML Structure

<!-- Hero section — cards scatter on scroll -->
<section class="hero" data-scroll-anim="hero-scatter">
  <div class="hero-cards">
    <div class="card" data-hero-card="1">...</div>
    <div class="card" data-hero-card="2">...</div>
    <div class="card" data-hero-card="3">...</div>
  </div>
</section>

<section class="about">...</section>

<!-- Services — pinned, header reveals -->
<section class="services" data-scroll-anim="services-pin">
  <div class="services-header">
    <h1>Stuff I make so you don't have to</h1>
  </div>
  <!-- mobile-cards fallback here -->
</section>

<!-- Fixed cards layer — enter, spread, converge, flip -->
<section class="cards" data-scroll-anim="cards-entrance">
  <div class="cards-container">
    <div class="card" data-flip-card="1">
      <div class="card-wrapper">
        <div class="flip-card-inner">
          <div class="flip-card-front">...</div>
          <div class="flip-card-back">...</div>
        </div>
      </div>
    </div>
    <div class="card" data-flip-card="2">...</div>
    <div class="card" data-flip-card="3">...</div>
  </div>
</section>

<section class="outro">...</section>

6. Animation Phases

Phase Progress What happens
Hero Scatter 0 → 75% Cards move up 350%, scale to 0.75, side cards spread ±90% with ±15° rotation, container fades to 0.5
Card Enter 0% → 40% Cards fly in from top (y -100%→50%), scale up (0.25→0.75), fade in
Card Settle 40% → 60% Cards settle to center (y 50%→0%), scale to full (0.75→1)
Card Flip 60% → 100% Cards converge from spread (±100%) to center, rotate Y 0→180° to reveal back face

7. Webstudio Integration

1. Add the three CDN <script> tags to your project's <head> code.
2. Paste all CSS into your project's global styles.
3. Build the HTML structure using Webstudio's element tree.
4. Add custom attributes on each element:
   — Section: data-scroll-anim = "hero-scatter"
   — Hero cards: data-hero-card = "1", "2", "3"
   — Services: data-scroll-anim = "services-pin"
   — Cards layer: data-scroll-anim = "cards-entrance"
   — Flip cards: data-flip-card = "1", "2", "3"
5. Paste the JavaScript into an embed <script> block before </body>.
6. Publish and scroll!