Enter the Frame

Quiet Control

Signal Drift

Signal Drift

Fluid Structures

Skyline Drift

Skyline Drift

Wired Thought

Neural Assembly

Neural Assembly

Silent Repetition

Learning Loop

Learning Loop

Loop Complete

1. CDN Scripts

Add these to your <head> or before closing </body> tag:

<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.5/gsap.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.5/ScrollTrigger.min.js"></script>
<script src="https://unpkg.com/lenis@1.1.18/dist/lenis.min.js"></script>

2. Required CSS

[data-sticky-cards] {
  position: relative;
  width: 100%;
  height: 100svh;
  overflow: hidden;
  background-color: #e3e3db;
  perspective: 1000px;
}

[data-sticky-cards] .sticky-card {
  position: absolute;
  top: 50%;
  left: 50%;
  width: 65%;
  height: 60%;
  display: flex;
  justify-content: center;
  align-items: center;
  gap: 1rem;
  padding: 2.5rem;
  border-radius: 1rem;
  color: #fff;
  transform-origin: center bottom;
  will-change: transform;
}

.sticky-card .col {
  flex: 1;
  height: 100%;
}

.sticky-card .col:nth-child(1) {
  display: flex;
  flex-direction: column;
  justify-content: space-between;
  padding: 0.5rem;
}

.sticky-card .col:nth-child(2) {
  border-radius: 0.75rem;
  overflow: hidden;
}

/* Responsive */
@media (max-width: 1000px) {
  [data-sticky-cards] .sticky-card {
    width: calc(100% - 4rem);
    height: 75%;
    flex-direction: column;
  }

  .sticky-card .col {
    width: 100%;
  }
}

3. 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);

  // Initialize each [data-sticky-cards] section
  document.querySelectorAll("[data-sticky-cards]").forEach((section) => {
    const cards = section.querySelectorAll(".sticky-card");
    const totalCards = cards.length;
    const segmentSize = 1 / totalCards;

    // Read config from data attributes
    const yOffset = parseFloat(section.dataset.cardsYOffset) || 5;
    const scaleStep = parseFloat(section.dataset.cardsScaleStep) || 0.075;
    const scrollLength = parseFloat(section.dataset.cardsScrollLength) || 8;
    const bg = section.dataset.cardsBg;

    if (bg) section.style.backgroundColor = bg;

    // Apply per-card colors and z-index from data attributes
    cards.forEach((card, i) => {
      const color = card.dataset.cardColor;
      const z = card.dataset.cardZ;
      if (color) card.style.backgroundColor = color;
      if (z) card.style.zIndex = z;

      gsap.set(card, {
        xPercent: -50,
        yPercent: -50 + i * yOffset,
        scale: 1 - i * scaleStep,
      });
    });

    // Scroll-driven animation
    ScrollTrigger.create({
      trigger: section,
      start: "top top",
      end: `+=${window.innerHeight * scrollLength}px`,
      pin: true,
      pinSpacing: true,
      scrub: 1,
      onUpdate: (self) => {
        const progress = self.progress;
        const activeIndex = Math.min(
          Math.floor(progress / segmentSize),
          totalCards - 1
        );
        const segProgress =
          (progress - activeIndex * segmentSize) / segmentSize;

        cards.forEach((card, i) => {
          if (i < activeIndex) {
            gsap.set(card, { yPercent: -250, rotationX: 35 });
          } else if (i === activeIndex) {
            gsap.set(card, {
              yPercent: gsap.utils.interpolate(-50, -200, segProgress),
              rotationX: gsap.utils.interpolate(0, 35, segProgress),
              scale: 1,
            });
          } else {
            const behindIndex = i - activeIndex;
            gsap.set(card, {
              yPercent: -50 + (behindIndex - segProgress) * yOffset,
              rotationX: 0,
              scale: 1 - (behindIndex - segProgress) * scaleStep,
            });
          }
        });
      },
    });
  });
});

4. HTML Usage

<!-- Sticky cards container (with defaults) -->
<section data-sticky-cards>
  <div class="sticky-card" data-card-color="#3d2fa9" data-card-z="5">
    <div class="col">
      <p>Subtitle</p>
      <h1>Title</h1>
    </div>
    <div class="col">
      <img src="image.jpg" alt="" />
    </div>
  </div>
  <!-- Add more .sticky-card divs... -->
</section>

<!-- Custom configuration -->
<section
  data-sticky-cards
  data-cards-bg="#1a1a1a"
  data-cards-y-offset="8"
  data-cards-scale-step="0.05"
  data-cards-scroll-length="10"
>
  <div class="sticky-card" data-card-color="#e74c3c" data-card-z="3">
    ...
  </div>
  <div class="sticky-card" data-card-color="#2ecc71" data-card-z="2">
    ...
  </div>
</section>

5. Data Attributes Reference

Container attributes (on the data-sticky-cards section):

Attribute Default Description
data-sticky-cards required Enables the sticky card animation on this section
data-cards-bg #e3e3db Background color of the pinned section
data-cards-y-offset 5 Vertical stacking offset between cards (yPercent units)
data-cards-scale-step 0.075 Scale reduction per card in the stack (0-1)
data-cards-scroll-length 8 Scroll distance multiplier (viewport heights)

Per-card attributes (on each .sticky-card):

Attribute Default Description
data-card-color none Background color for this card
data-card-z none z-index stacking order (higher = on top)

6. How It Works

The animation uses GSAP ScrollTrigger to pin the section and scrub through a scroll-driven card stack. Each card starts centered and slightly offset/scaled behind the previous one. As the user scrolls:

1. The active card tilts upward with rotationX and slides out of view via yPercent.
2. Cards behind the active card smoothly shift forward, scaling up to fill the gap.
3. Cards already dismissed stay offscreen above.

Lenis provides smooth inertial scrolling for a polished feel. The entire animation is configured via data attributes — no JS changes needed to customize behavior.