跪拜 Guibai
← Back to the summary

Building a Pinned Scroll Narrative with GSAP and Vue

Preparation

Tech stack: Vue + GSAP

I was working on an animation recently and specifically looked it up online — it's called pinned scroll storytelling. It felt quite interesting, so I'm sharing it with you.

The animation looks like this:

Kapture 2026-008-11 at 00.14.17.gif

The corresponding reference site is: https://www.vaporesso.com/series-product/xros-series/xros6

Thought Process 🤔

Scroll through the animation above a few times, and you can roughly get a superficial feel for it.

When the page scrolls to a certain section, the screen suddenly stops. You keep scrolling, but the page doesn't move down — instead, it starts playing like a timeline: the card moves to the center, enlarges, the image and text pull apart, then the card exits, and the content behind it layers in one by one. Once this segment finishes, the card returns to its original position, and the next card enters the center of the page, repeating a similar sequence.

From the animation above, you can see that the page is actually fixed in the middle of the screen the whole time; it's just that mouse scrolling controls the playback progress of the GSAP timeline. I believe those familiar with GSAP will find this very familiar.

Now that we have a rough idea, the next step is how to structure the page.

Step 1: Break Down the DOM

Based on what we know, we can split the actions into two parts: the first part is the card, and the second part is the detail content panel.

So you can roughly arrive at a DOM structure like this:

<template>
  <div class="container">
    <!-- Stage fixed on the screen -->
    <div class="story-stage">
      <!-- First layer: overview card track -->
      <div class="story-card">
        <article class="story-item"></article>
        <article class="story-item"></article>
        <article class="story-item"></article>
      </div>

      <!-- Second layer: detail content panel -->
      <div class="story-panel">
        <section class="story-section"></section>
        <section class="story-section"></section>
        <section class="story-section"></section>
      </div>
    </div>
  </div>
</template>

This step establishes the hierarchical relationship:

  1. container: responsible for stretching the scroll distance.
  2. story-stage: the stage fixed within the viewport.
  3. story-card: a track composed of three cards, which will be moved as a whole later.
  4. story-panel: a stacking container for the detail content.
  5. story-section: each detail floor that needs to fade in and out.

Step 2: Determine the Layout Relationship

Now that the DOM is ready, the next step is to determine the layout relationships. In the first step, we split the DOM into the card track, detail panel, and fixed stage, so we can write simplified CSS.

.container {
  position: relative;
  height: 1200vh;
}

.story-stage {
  position: sticky;
  top: 0;
  height: 100vh;
  overflow: hidden;
}

.story-card,
.story-panel {
  position: absolute;
  inset: 0;
}

.story-card {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  align-items: center;
}

.story-section {
  position: absolute;
  inset: 0;
  opacity: 0;
  visibility: hidden;
}

This way, when the page scrolls, what the scrollbar actually advances is the height of the container, while the picture the user sees always stays within the story-stage screen.

The subsequent animations are essentially state transitions performed within this fixed stage.

Step 3: Prepare the Data Structure

After the layout is determined, you can think about the data structure.

Because one card corresponds to one set of detail content, it's not suitable to hardcode the DOM here. A better approach is to use a single storyGroups data source to generate both the cards and the detail sections.

const storyGroups= [
  {
    id: 'signal',
    kicker: 'Insight Layer',
    title: 'City Signals',
    description: 'Extract trends from neighborhoods, traffic, and real-time events.',
    accent: '#35b7a8',
    cardImage:
      'https://images.unsplash.com/photo-1494526585095-c41746248156?auto=format&fit=crop&w=1200&q=80',
    panels: [
      {
        id: 'map',
        kicker: 'Live Map',
        title: 'Neighborhood heat compressed into one screen',
        description: 'Use layered images to express spatial changes; scrolling only changes the current floor opacity, without generating ordinary page floors.',
        image:
          'https://images.unsplash.com/photo-1518005020951-eccb494ad742?auto=format&fit=crop&w=1600&q=80',
        stats: [
          { value: '12', label: 'Key Neighborhoods' },
          { value: '4.8x', label: 'Peak Change' },
        ],
      },
      {
        id: 'motion',
        kicker: 'Video',
        title: 'Video floor replays on entry',
        description: 'The current video only plays when its corresponding content fully enters, and pauses immediately after leaving or being covered by the next layer.',
        image:
          'https://images.unsplash.com/photo-1497366754035-f200968a6e72?auto=format&fit=crop&w=1600&q=80',
        video: 'https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4',
      },
      {
        id: 'brief',
        kicker: 'Decision',
        title: 'Finally converge into an action summary',
        description: 'After the same group of content finishes playing, the card returns to its initial position, making way for the next narrative group.',
        image:
          'https://images.unsplash.com/photo-1500530855697-b586d89ba3ee?auto=format&fit=crop&w=1600&q=80',
        stats: [
          { value: '82%', label: 'Signal Hit Rate' },
          { value: '18m', label: 'Refresh Cycle' },
        ],
      },
    ],
  },
  {
    id: 'craft',
    kicker: 'Product Layer',
    title: 'Product Craftsmanship',
    description: 'Break down materials, structure, and details into continuous shots.',
    accent: '#e86d5b',
    cardImage:
      'https://images.unsplash.com/photo-1518005020951-eccb494ad742?auto=format&fit=crop&w=1200&q=80',
    panels: [
      {
        id: 'material',
        kicker: 'Material',
        title: 'Material shot first occupies the full screen',
        description: 'Image floors are absolutely positioned within the pinned container; previous and next content cross-fades via the scrub timeline.',
        image:
          'https://images.unsplash.com/photo-1500530855697-b586d89ba3ee?auto=format&fit=crop&w=1600&q=80',
        stats: [
          { value: '0.6mm', label: 'Edge Precision' },
          { value: '32', label: 'Process Steps' },
        ],
      },
      {
        id: 'assembly',
        kicker: 'Assembly',
        title: 'Structural details switch within the same screen',
        description: 'Not a jump, not an anchor, not a subsequent ordinary floor, but a continuous segment of the same timeline.',
        image:
          'https://images.unsplash.com/photo-1497366216548-37526070297c?auto=format&fit=crop&w=1600&q=80',
      },
      {
        id: 'quality',
        kicker: 'Quality',
        title: 'Quality inspection data then overlays',
        description: 'After each group of content ends, the overview card reappears, shrinks, and restores its horizontal arrangement.',
        image:
          'https://images.unsplash.com/photo-1518005020951-eccb494ad742?auto=format&fit=crop&w=1600&q=80',
        stats: [
          { value: '99.2%', label: 'Pass Rate' },
          { value: '7', label: 'Key Inspections' },
        ],
      },
    ],
  },
  {
    id: 'future',
    kicker: 'Experience Layer',
    title: 'Future Experience',
    description: 'Use scrolling to connect scenes, emotions, and final states.',
    accent: '#d2a63f',
    cardImage:
      'https://images.unsplash.com/photo-1497366216548-37526070297c?auto=format&fit=crop&w=1200&q=80',
    panels: [
      {
        id: 'scene',
        kicker: 'Scene',
        title: 'First act establishes the scene relationship',
        description: 'After the card enters the center, it enlarges; the internal image moves up, the copy moves down, then the stage is handed over to the content floor.',
        image:
          'https://images.unsplash.com/photo-1497366754035-f200968a6e72?auto=format&fit=crop&w=1600&q=80',
      },
      {
        id: 'loop',
        kicker: 'Video',
        title: 'Dynamic clip carries the emotional peak',
        description: 'The video starts from the beginning when displayed, pauses after scrolling away, and realigns playback state when scrolling back in.',
        image:
          'https://images.unsplash.com/photo-1500530855697-b586d89ba3ee?auto=format&fit=crop&w=1600&q=80',
        video: 'https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4',
      },
      {
        id: 'finish',
        kicker: 'Final',
        title: 'Finally return to the full overview',
        description: 'After the third group ends, the sticky positioning ends, and the page continues scrolling down.',
        image:
          'https://images.unsplash.com/photo-1494526585095-c41746248156?auto=format&fit=crop&w=1600&q=80',
        stats: [
          { value: '3', label: 'Overview Cards' },
          { value: '9', label: 'Layered Content' },
        ],
      },
    ],
  },
]

The benefit of this is that the animation flow only needs to be written once. If you want to add a fourth card later, you just keep adding data to the array.

Step 4: Calculate the Track Movement Distance

There's a key point here: I need to move the entire card track, not just a single card.

So when a specific card needs to enter the center, it's essentially calculating:

How much the entire track needs to move so that the center point of the current card aligns with the center point of the stage.

const getMoveX = targetCard => () => {
  const stageRect = stageRef.value.getBoundingClientRect()
  const cardRect = targetCard.getBoundingClientRect()
  const stageCenter = stageRect.left + stageRect.width / 2
  const cardCenter = cardRect.left + cardRect.width / 2
  return stageCenter - cardCenter
}

The distance the track needs to move = Stage center point - Current card center point.

If the result is negative, it means the card is to the right of the stage center, and the track needs to move left; if the result is positive, it means the card is to the left of the stage center, and the track needs to move right.

Step 5: Choreograph the Card Animation

After having the track movement distance, you can start choreographing the animation for a single card.

The animation for each card can be seen as the same set of steps:

  1. Find the current card;
  2. Find all other cards except the current one;
  3. Move the entire track so the current card enters the center;
  4. Fade out the other cards;
  5. Enlarge and fade out the current card;
  6. Move the image up, move the copy down;
  7. Fade out the entire track, handing over to the subsequent detail sections.
const addCardSequence = (timeline, group) => {
  const cardNodes = getCardNodes(group.id)
  if (!cardNodes) return

  const { card, image, copy } = cardNodes
  const cards = cardRef.value
  const cardTrack = cardTrackRef.value
  if (!cardTrack) return

  const otherCards = cards.filter(item => item !== card)
  const currentPanels = getPanelsByGroupId(group.id)

  // First segment: Move the entire track until the target card is centered, then enlarge and fade out the card, handing over to the detail panel.
  timeline
    .set(cards, { zIndex: 1 })
    .set(card, { zIndex: 3 })
    .set(cardTrack, { autoAlpha: 1 })
    .to(cardTrack, { x: getMoveX(card), duration: 0.85 })
    .to(otherCards, { opacity: 0, scale: 0.94, filter: 'saturate(0.55)', duration: 0.38 }, '<+=0.12')
    .to(card, { opacity: 0, scale: 4, duration: 0.72 }, '<+=0.18')
    .to(image, { y: -54, scale: 1.06, duration: 0.72 }, '<')
    .to(copy, { y: 58, opacity: 0, duration: 0.58 }, '<')
    .to(cardTrack, { autoAlpha: 0, duration: 0.28 }, '<+=0.35')

  // Middle content
  addPanelSequence(timeline, currentPanels, '<+=0.18')

  // Second segment: After the detail panel ends, first restore the current card's size, then move the entire track back to its initial position.
  timeline
    .to(cardTrack, { autoAlpha: 1, duration: 0.28 }, '>')
    .to(card, { opacity: 1, scale: 1, duration: 0.75 }, '<')
    .to(image, { y: 0, scale: 1, duration: 0.75 }, '<')
    .to(copy, { y: 0, opacity: 1, duration: 0.65 }, '<')
    .to(cardTrack, { x: 0, duration: 0.75 }, '>')
    .to(otherCards, { opacity: 1, scale: 1, filter: 'saturate(1)', duration: 0.7 }, '<+=0.12')
    .set(card, { zIndex: 1 })
}

Here, the card uses opacity, not autoAlpha.

Because autoAlpha additionally controls visibility, which might leave subsequent cards in a hidden state when scrolling backwards. A single card only needs a transparency change, so using opacity is more appropriate.

Step 6: Handle Detail Section Switching

After the card exits, the detail content behind it starts appearing in sequence.

The logic for each section is the same: fade in, stay, fade out.

const addPanelSequence = (timeline, panels) => {
  panels.forEach((panel, index) => {
    const previousPanel = panels[index - 1]

    if (previousPanel) {
      timeline.to(previousPanel, { autoAlpha: 0, duration: 0.45 }, '>')
    }

    timeline
      .to(panel, { autoAlpha: 1, duration: 0.55 }, previousPanel ? '<' : '>')
      .to({}, { duration: 0.75 })
  })
}

The .to({}, { duration: 0.75 }) here is an empty animation.

It doesn't change any DOM, it just occupies timeline length. Because the animation is controlled by scrolling, its purpose is to leave reading time for the current section.

Step 7: Card Restoration and Track Return

After the detail content finishes playing, you can't just reset the card back to its original state abruptly.

A more natural sequence should be: first shrink the current card back to its original size, then move the entire track back to its initial position.

timeline
  .to(cardTrack, { autoAlpha: 1, duration: 0.28 }, '>')
  .to(card, { opacity: 1, scale: 1, duration: 0.75 }, '<')
  .to(image, { y: 0, scale: 1 }, '<')
  .to(copy, { y: 0, opacity: 1 }, '<')
  .to(cardTrack, { x: 0, duration: 0.75 }, '>')
  .to(otherCards, { opacity: 1, scale: 1, filter: 'saturate(1)' }, '<+=0.12')

This way, the user can see the card retract from the detail state back to the overview state, making the transition more complete.

Step 8: Bind ScrollTrigger

Finally, hand the entire timeline over to ScrollTrigger for control.

const timeline = gsap.timeline({
  defaults: { ease: 'power2.inOut' },
  scrollTrigger: {
    trigger: containerRef.value,
    start: 'top top',
    end: 'bottom bottom',
    scrub: 1,
    invalidateOnRefresh: true,
  },
})

scrub: 1 means the scroll progress and animation progress are bound, with a slight buffer. invalidateOnRefresh: true is important because the track movement distance is calculated dynamically and needs to be recalculated when the window size changes.

Step 9: Handle Video Playback

If the detail section only contains images, the animation switching basically ends here. But if a section contains a video, you need extra handling for the playback state.

The reason is simple: a video won't automatically pause just because its section's opacity becomes 0. If not handled, two problems might occur:

  1. The section has faded out, but the video is still playing in the background;
  2. When the user scrolls back, the video doesn't start from the beginning but continues playing from the middle.

So here, you need to dynamically control video playback based on the currently visible section.

First, define a variable to record the currently playing video:

let activeVideo = null

Then encapsulate a method to play a video from the start:

const playFromStart = video => {
  try {
    video.currentTime = 0
    video.play().catch(() => undefined)
  } catch {
    video.play().catch(() => undefined)
  }
}

try...catch and .catch() are used here because browsers have autoplay policy restrictions. Although the video has already been set with muted and playsinline, video.play() might still fail, so this acts as a fallback to prevent playback failure from affecting the main animation.

Next, encapsulate a method to pause all videos:

const pauseAllVideos = () => {
  activeVideo = null

  containerRef.value?.querySelectorAll('video').forEach(video => {
    video.pause()
  })
}

Then, when initializing the animation, first find all videos and the sections they belong to:

const videos = Array.from(containerRef.value.querySelectorAll('video'))

const videoPanels = videos
  .map(video => ({
    video,
    panel: video.closest('.story-section'),
  }))
  .filter(item => item.panel)

With this mapping relationship, you can determine which video should play when the timeline updates:

const syncVideos = () => {
  const visibleVideo =
    videoPanels.find(({ panel }) => Number(gsap.getProperty(panel, 'opacity')) > 0.65)
      ?.video ?? null

  videos.forEach(video => {
    if (video !== visibleVideo) {
      video.pause()
    }
  })

  if (!visibleVideo || activeVideo === visibleVideo) {
    if (!visibleVideo) activeVideo = null
    return
  }

  activeVideo = visibleVideo
  playFromStart(visibleVideo)
}

The judgment logic here is:

Finally, attach syncVideos to the timeline's onUpdate:

const timeline = gsap.timeline({
  defaults: { ease: 'power2.inOut' },
  onUpdate: syncVideos,
  scrollTrigger: {
    trigger: containerRef.value,
    start: 'top top',
    end: 'bottom bottom',
    scrub: 1,
    invalidateOnRefresh: true,
    onLeave: pauseAllVideos,
    onLeaveBack: pauseAllVideos,
  },
})

Here, onLeave and onLeaveBack do only one thing: pause all videos when leaving the current scroll animation area.

After this handling, the video is bound to the section's display state. The section appears, the video plays from the start; the section leaves, the video pauses.

Step 10: Clean Up Animation on Component Unmount

The final step is cleanup.

In a Vue single-page application, components unmount when navigating away. If you don't clean up ScrollTrigger and GSAP animations, the next time you enter the page, you might encounter issues like multiple timelines existing simultaneously, duplicate scroll triggers, and chaotic animation states.

So here, gsap.context() is used to manage the animations within the current component:

let animationContext = null

onMounted(async () => {
  await nextTick()

  if (!isReady()) {
    return
  }

  animationContext = gsap.context(() => {
    // Initialize state
    gsap.set(cardTrackRef.value, { autoAlpha: 1, x: 0 })
    gsap.set(cardRef.value, {
      opacity: 1,
      scale: 1,
      transformOrigin: 'center center',
    })
    gsap.set(panelRef.value, { autoAlpha: 0 })

    // Create timeline
    const timeline = gsap.timeline({
      defaults: { ease: 'power2.inOut' },
      onUpdate: syncVideos,
      scrollTrigger: {
        trigger: containerRef.value,
        start: 'top top',
        end: 'bottom bottom',
        scrub: 1,
        invalidateOnRefresh: true,
        onLeave: pauseAllVideos,
        onLeaveBack: pauseAllVideos,
      },
    })

    storyGroups.forEach(group => addCardSequence(timeline, group))
    timeline.call(pauseAllVideos)
  }, containerRef.value)

  requestAnimationFrame(() => ScrollTrigger.refresh())
})

The benefit of gsap.context() is that it records the GSAP animations and ScrollTriggers created within this callback. When the component unmounts, you only need to call:

onBeforeUnmount(() => {
  pauseAllVideos()
  animationContext?.revert()
})

Two things are done here:

  1. pauseAllVideos(): Pause any videos that might still be playing;
  2. animationContext?.revert(): Revert the animations and ScrollTriggers created in the current component.

This way, after navigating away, no animations linger. When entering the page again, a clean set of timelines will be recreated.

Complete Code

Code Address

Final Words

Although a large part of the code above wasn't written by me but was generated by AI, then broken down, debugged, and modified step by step, this actually made me more certain of one thing: in the AI era, coding ability hasn't become less important; it has just taken on a more practical form of existence.

AI can quickly produce a result that looks runnable, and the animation effects can even be stunning enough. But when you actually put it into your own project, you'll encounter many places that require judgment and fine-tuning.

Why should the scroll height be set this way? Why, after the card enlarges and fades out, should it first restore its original size before moving the entire track? Why does autoAlpha affect whether an element can reappear when scrolling backwards? Why should video playback synchronize with the visible state of the detail section? Why does a tiny timing adjustment affect the smoothness of the entire animation?

AI can give you an answer to these questions, but the person ultimately responsible is still you. You need to understand the DOM structure it generates, the timeline sequence, state switching, and boundary handling to know where you can delete, where you can't touch, where it just "looks complex," and where it's actually ensuring the stability and smoothness of the animation.

So I increasingly feel that AI isn't letting us avoid writing code, but rather pushing the focus of "writing code" one layer forward: from typing out implementations line by line, to understanding structures, verifying logic, discovering problems, and making precise adjustments. Whether you can understand AI code determines whether you can truly take it over; whether you can fine-tune details determines whether this result can go from a demo to something usable in a project.

Follow me, a frontend developer who still seriously reads code, breaks down code, and modifies code in the AI era. I will continue to share frontend practices, animation replicas, source code understanding, and AI-assisted development experiences. I hope we can all run faster with the help of AI, and also walk more steadily with a solid coding foundation.