Instructions

This template uses GSAP for sliders, marquees, scroll-based movement, stacking cards, horizontal scrolling, parallax images, and SVG drawing effects.

Before editing the code, duplicate the template or save a backup version. After making changes, save the project and republish the site.

GSAP Animations

Some GSAP animations are added in the global site custom code, while others are added only to specific pages.

To find gsap animations open the Webflow project → Go to Site settings → Open the Custom code tab → Footer code areas. Or Specific Page Settings → Custom Code → Before </body> . After editing, click Save changes. Publish the site to see the changes live.

01. Smooth Scroll

Makes the whole page scroll smoothly with a soft gliding feel instead of the default jumpy browser scrolling.

The lerp values control the smoothness. 0.1 . Smaller numbers feel floatier, bigger numbers feel closer to normal scrolling. To turn smooth scrolling off completely, just delete this script.

<!-- Smooth Scroll -->
<script>
if (matchMedia("(min-width: 769px)").matches) {
  addEventListener("load", () => {
    const startLenis = () => {
      if (
        typeof Lenis === "undefined" ||
        typeof gsap === "undefined" ||
        typeof ScrollTrigger === "undefined"
      ) return;

      window.lenis = new Lenis({
        lerp: 0.1,
        smoothWheel: true,
        syncTouch: false
      });

      window.lenis.on("scroll", ScrollTrigger.update);
      gsap.ticker.add(time => window.lenis.raf(time * 1000));
      gsap.ticker.lagSmoothing(0);

      const resize = () => {
        window.lenis.resize();
        ScrollTrigger.refresh();
      };

      new ResizeObserver(resize).observe(document.body);
      ScrollTrigger.addEventListener("refresh", () => window.lenis.resize());

      if (document.fonts?.ready) document.fonts.ready.then(resize);
    };

    if (typeof Lenis !== "undefined") {
      startLenis();
      return;
    }
    const script = document.createElement("script");
    script.src = "https://unpkg.com/lenis@1.3.21/dist/lenis.min.js";
    script.onload = startLenis;
    document.head.appendChild(script);
  }, { once: true });
}
</script>

02. 3D Flip Button

Turns the button into a 3D cube that flips upward on hover (and keyboard focus), revealing a second face.

The text and colors of both faces directly in the Designer. Select the is-front or is-top layer and change its text or background color like any other element. In the code, duration: 0.45 (appears twice) controls the flip speed, smaller is faster.

1<!-- Button 3D flip hover effect — desktop only -->
2<script>
3window.Webflow ||= [];
4window.Webflow.push(() => {
5  if (typeof gsap === "undefined") return;
6
7  const mm = gsap.matchMedia();
8
9  mm.add(
10    "(min-width: 992px) and (hover: hover) and (pointer: fine)",
11    () => {
12      const buttons = gsap.utils.toArray(".button-3d");
13      if (!buttons.length) return;
14
15      const cleanups = [];
16
17      buttons.forEach(button => {
18        const cube = button.querySelector(".button-cube");
19        const front = button.querySelector(".is-front");
20        const top = button.querySelector(".is-top");
21
22        if (!cube || !front || !top) return;
23
24        const setup = () => {
25          const depth = cube.offsetHeight / 2;
26
27          gsap.set(button, {
28            perspective: 800
29          });
30
31          gsap.set(cube, {
32            z: -depth,
33            rotationX: 0,
34            transformOrigin: "50% 50%",
35            transformStyle: "preserve-3d"
36          });
37
38          gsap.set(front, {
39            position: "relative",
40            backfaceVisibility: "hidden",
41            transform: `translateZ(${depth}px)`
42          });
43
44          gsap.set(top, {
45            position: "absolute",
46            inset: 0,
47            backfaceVisibility: "hidden",
48            transform: `rotateX(-90deg) translateZ(${depth}px)`
49          });
50        };
51
52        const hoverIn = () => {
53          gsap.to(cube, {
54            rotationX: 90,
55            duration: 0.45,
56            ease: "power3.inOut",
57            overwrite: true
58          });
59        };
60
61        const hoverOut = () => {
62          gsap.to(cube, {
63            rotationX: 0,
64            duration: 0.45,
65            ease: "power3.inOut",
66            overwrite: true
67          });
68        };
69
70        setup();
71
72        button.addEventListener("mouseenter", hoverIn);
73        button.addEventListener("mouseleave", hoverOut);
74        button.addEventListener("focusin", hoverIn);
75        button.addEventListener("focusout", hoverOut);
76
77        cleanups.push(() => {
78          button.removeEventListener("mouseenter", hoverIn);
79          button.removeEventListener("mouseleave", hoverOut);
80          button.removeEventListener("focusin", hoverIn);
81          button.removeEventListener("focusout", hoverOut);
82
83          gsap.killTweensOf(cube);
84          gsap.set([button, cube, front, top], {
85            clearProps: "all"
86          });
87        });
88      });
89
90      let resizeFrame;
91
92      const handleResize = () => {
93        cancelAnimationFrame(resizeFrame);
94
95        resizeFrame = requestAnimationFrame(() => {
96          buttons.forEach(button => {
97            const cube = button.querySelector(".button-cube");
98            const front = button.querySelector(".is-front");
99            const top = button.querySelector(".is-top");
100
101            if (!cube || !front || !top) return;
102
103            const depth = cube.offsetHeight / 2;
104
105            gsap.set(cube, { z: -depth });
106            gsap.set(front, {
107              transform: `translateZ(${depth}px)`
108            });
109            gsap.set(top, {
110              transform: `rotateX(-90deg) translateZ(${depth}px)`
111            });
112          });
113        });
114      };
115
116      window.addEventListener("resize", handleResize);
117
118      return () => {
119        cancelAnimationFrame(resizeFrame);
120        window.removeEventListener("resize", handleResize);
121        cleanups.forEach(cleanup => cleanup());
122      };
123    }
124  );
125});
126</script>

03. Nav Links Roll on Hover

Makes navigation links do a rolling flip on hover, where the text rotates away and a copy rolls in from below (desktop only).

The link text in the Designer, but make sure both text layers inside the link (the lbl and its lbl clone) say the same thing. In the code, duration: 0.3 controls the roll speed, smaller is faster.

1<!-- Navlinks on hover -->
2<script>
3window.Webflow ||= [];
4
5window.Webflow.push(() => {
6  if (typeof gsap === "undefined") return;
7
8  const mm = gsap.matchMedia();
9
10  mm.add(
11    "(min-width: 992px) and (hover: hover) and (pointer: fine)",
12    () => {
13      const cleanups = [];
14
15      gsap.utils.toArray(".roll-link").forEach((link) => {
16        const roll = link.querySelector(".roll");
17        const original = roll?.querySelector(".lbl:not(.clone)");
18        const clone = roll?.querySelector(".lbl.clone");
19
20        if (!roll || !original || !clone) return;
21
22        gsap.set(clone, {
23          yPercent: 100,
24          rotationX: -90,
25          transformOrigin: "center top"
26        });
27
28        gsap.set(original, {
29          yPercent: 0,
30          rotationX: 0,
31          transformOrigin: "center bottom"
32        });
33
34        const timeline = gsap.timeline({
35          paused: true,
36          defaults: {
37            duration: 0.3,
38            ease: "sine.inOut"
39          }
40        });
41
42        timeline
43          .to(
44            original,
45            {
46              yPercent: -100,
47              rotationX: 90
48            },
49            0
50          )
51          .to(
52            clone,
53            {
54              yPercent: 0,
55              rotationX: 0
56            },
57            0
58          );
59
60        const hoverIn = () => timeline.play();
61        const hoverOut = () => timeline.reverse();
62
63        link.addEventListener("mouseenter", hoverIn);
64        link.addEventListener("mouseleave", hoverOut);
65        link.addEventListener("focusin", hoverIn);
66        link.addEventListener("focusout", hoverOut);
67
68        cleanups.push(() => {
69          link.removeEventListener("mouseenter", hoverIn);
70          link.removeEventListener("mouseleave", hoverOut);
71          link.removeEventListener("focusin", hoverIn);
72          link.removeEventListener("focusout", hoverOut);
73
74          timeline.kill();
75        });
76      });
77
78      return () => {
79        cleanups.forEach((cleanup) => cleanup());
80      };
81    }
82  );
83});
84</script>

04. Count-Up Numbers

Animates numbers from 0 when they enter the viewport.

Edit duration: 3 to control the animation speed.

1<!-- Simple Count-up -->
2<script>
3window.Webflow ||= [];
4window.Webflow.push(() => {
5  if (typeof gsap === "undefined" || typeof ScrollTrigger === "undefined") return;
6  gsap.registerPlugin(ScrollTrigger);
7
8  gsap.utils.toArray(".number").forEach((el) => {
9    const raw = el.textContent.trim();
10    const m = raw.match(/[-+]?(?:\d+\.?\d*|\.\d+)/);
11    if (!m || !Number.isFinite(+m[0])) return;
12
13    const end = +m[0];
14    const decimals = (m[0].split('.')[1] || '').length;
15    const prefix = raw.slice(0, m.index);
16    const suffix = raw.slice(m.index + m[0].length);
17    const obj = { val: 0 };
18
19    el.textContent = prefix + (0).toFixed(decimals) + suffix;
20
21    gsap.to(obj, {
22      val: end,
23      duration: 3,
24      ease: "power1.inout",
25      scrollTrigger: { trigger: el, start: "top 85%", once: true },
26      onUpdate: () => {
27        el.textContent = prefix + obj.val.toFixed(decimals) + suffix;
28      }
29    });
30  });
31});
32</script>

05. Hero Images Change

Automatically changes hero images with a smooth fade and zoom effect.

Interval, duration, and startDelay at the top of the script to control the timing.

1<!-- Hero Images Changing -->
2<script>
3window.Webflow ||= [];
4window.Webflow.push(() => {
5  if (typeof gsap === "undefined") return;
6  if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
7
8  const CFG = {
9    desktop: { interval: 3, duration: 1.5, startDelay: 2 },
10    mobile:  { interval: 4, duration: 1,   startDelay: 3 },
11    bloom: 1.14, land: 1.05, from: 1.12, inView: 0.15
12  };
13
14  const defocused = () => `brightness(${CFG.bloom})`;
15  const focused = "brightness(1)";
16
17  function makeState(imgs) {
18    if (!imgs.length) return null;
19    gsap.set(imgs, { autoAlpha: 0, scale: CFG.from, filter: "brightness(1)", willChange: "filter, transform, opacity" });
20    gsap.set(imgs[0], { autoAlpha: 1, scale: CFG.land, filter: focused });
21    return { imgs, index: 0, drift: null, transition: null };
22  }
23
24  function drift(state, img, visibleTime, delay = 0) {
25    state.drift?.kill();
26    state.drift = gsap.to(img, {
27      scale: 1, delay, ease: "none", overwrite: "auto",
28      duration: Math.max(visibleTime - delay, 0.25)
29    });
30  }
31
32  function swap(state, visibleTime, duration) {
33    if (!state || state.imgs.length < 2) return;
34    state.transition?.kill();
35    state.drift?.kill();
36
37    const current = state.imgs[state.index];
38    state.index = (state.index + 1) % state.imgs.length;
39    const next = state.imgs[state.index];
40
41    state.transition = gsap.timeline({ defaults: { overwrite: "auto" } })
42      .to(current, { autoAlpha: 0, scale: "+=0.05", filter: defocused(), duration, ease: "sine.in" })
43      .fromTo(next,
44        { autoAlpha: 0, scale: CFG.from, filter: defocused() },
45        { autoAlpha: 1, scale: CFG.land, filter: focused, duration: duration * 1.3, ease: "power3.out" },
46        duration * 0.35
47      );
48
49    drift(state, next, visibleTime, duration * 1.65);
50  }
51
52  function run(states, config, visibleTime, pick, section) {
53    let isVisible = false;
54
55    states.forEach(s => drift(s, s.imgs[s.index], visibleTime, 0));
56
57    const timer = gsap.delayedCall(config.interval, function tick() {
58      swap(pick(), visibleTime, config.duration);
59      timer.restart(true);
60      if (!isVisible) timer.pause();
61    }).pause();
62
63    function setPaused(paused) {
64      timer.paused(paused);
65      states.forEach(s => {
66        s.drift?.paused(paused);
67        s.transition?.paused(paused);
68      });
69    }
70
71    setPaused(true);
72
73    const observer = new IntersectionObserver(([entry]) => {
74      isVisible = entry.isIntersecting;
75      setPaused(!isVisible);
76    }, { threshold: CFG.inView });
77
78    const starter = gsap.delayedCall(config.startDelay, () => observer.observe(section));
79
80    return () => {
81      starter.kill();
82      timer.kill();
83      observer.disconnect();
84      states.forEach(s => { s.drift?.kill(); s.transition?.kill(); });
85    };
86  }
87
88  const mm = gsap.matchMedia();
89
90  /* Desktop and tablet */
91  mm.add("(min-width: 768px)", () => {
92    const wraps = gsap.utils.toArray(".hero-image-wrap");
93    if (!wraps.length) return;
94
95    const states = wraps
96      .map(w => makeState(gsap.utils.toArray(w.querySelectorAll(".hero-img"))))
97      .filter(Boolean);
98    if (!states.length) return;
99
100    let bag = [], last = -1;
101    function pick() {
102      if (!bag.length) {
103        bag = gsap.utils.shuffle(states.map((_, i) => i));
104        if (bag.length > 1 && bag[0] === last) bag.push(bag.shift());
105      }
106      last = bag.shift();
107      return states[last];
108    }
109
110    const section = wraps[0].closest("section") || wraps[0].parentElement;
111    return run(states, CFG.desktop, CFG.desktop.interval * states.length, pick, section);
112  });
113
114  /* Mobile */
115  mm.add("(max-width: 767px)", () => {
116    const wraps = gsap.utils.toArray(".hero-image-wrap");
117    const imgs  = gsap.utils.toArray(".hero-img");
118    if (!wraps.length || !imgs.length) return;
119
120    const visibleWraps = wraps.filter(w => {
121      const r = w.getBoundingClientRect(), s = getComputedStyle(w);
122      return s.display !== "none" && s.visibility !== "hidden" && r.width > 0 && r.height > 0;
123    });
124
125    const host = visibleWraps[visibleWraps.length - 1] || wraps[0];
126
127    /* Comment markers let each image be restored to its exact position */
128    const placements = imgs.map(img => {
129      const marker = document.createComment("hero-image-original-position");
130      img.parentNode.insertBefore(marker, img);
131      host.appendChild(img);
132      return { img, marker, loading: img.getAttribute("loading") };
133    });
134
135    /* Lazy images inside hidden wraps: start loading now */
136    imgs.forEach(img => { img.loading = "eager"; });
137
138    gsap.set(host, { position: "relative", overflow: "hidden" });
139    gsap.set(imgs, { display: "block", position: "absolute", inset: 0, width: "100%", height: "100%", objectFit: "cover" });
140
141    const state = makeState(imgs);
142    const section = host.closest("section") || host.parentElement;
143    const stop = run([state], CFG.mobile, CFG.mobile.interval, () => state, section);
144
145    return () => {
146      stop();
147      placements.forEach(({ img, marker, loading }) => {
148        if (marker.parentNode) {
149          marker.parentNode.insertBefore(img, marker);
150          marker.remove();
151        }
152        loading === null ? img.removeAttribute("loading") : img.setAttribute("loading", loading);
153      });
154    };
155  });
156});
157</script>

06. Services 3D Cube

Changes service images with a 3D cube rotation while scrolling.

DURATION to control the rotation speed.

1<!-- Services 3D Cube -->
2<script>
3  window.Webflow ||= [];
4  window.Webflow.push(() => {
5    if (!window.gsap || !window.ScrollTrigger) return;
6
7    gsap.registerPlugin(ScrollTrigger);
8
9    const wrap = document.querySelector('.services-wrapper');
10    if (!wrap) return;
11
12    let initialized = false;
13
14    function initCube() {
15      if (initialized) return;
16      initialized = true;
17
18      const scene = wrap.querySelector('.service-cube-stage') || wrap.querySelector('.services-right-sticky');
19
20      const panels = [...wrap.querySelectorAll('.service-panel')];
21
22      if (!scene || panels.length < 2) return;
23
24      const desktop = matchMedia('(min-width: 769px)').matches;
25
26      const items = desktop ? [...wrap.querySelectorAll('.service-item')] : [];
27
28      const count = panels.length;
29      const duration = 0.5;
30
31      let current = 0;
32      let target = 0;
33      let busy = false;
34      let activeItem = null;
35      let depth = 0;
36
37      const stores = panels.map((panel) => {
38        const fragment = document.createDocumentFragment();
39
40        fragment.append(...panel.childNodes);
41        panel.style.display = 'none';
42
43        return fragment;
44      });
45
46      const createElement = (className) => Object.assign(document.createElement('div'), { className });
47
48      const turner = createElement('cube-turner');
49      const front = createElement('cube-face cube-face-front');
50      const side = createElement('cube-face cube-face-side');
51
52      turner.append(front, side);
53      scene.appendChild(turner);
54
55      const itemMap = new Map(items.map((item, index) => [item.dataset.service || String(index), item]));
56
57      function setActive(index) {
58        if (!items.length) return;
59
60        activeItem?.classList.remove('is-active');
61
62        activeItem = itemMap.get(String(index)) || items[index] || null;
63
64        activeItem?.classList.add('is-active');
65      }
66
67      gsap.set(scene, {
68        overflow: 'visible',
69        perspective: 1200,
70        perspectiveOrigin: '50% 50%',
71      });
72
73      gsap.set(turner, {
74        position: 'absolute',
75        inset: 0,
76        width: '100%',
77        height: '100%',
78        rotationY: 0,
79        transformOrigin: '50% 50%',
80        transformStyle: 'preserve-3d',
81      });
82
83      gsap.set([front, side], {
84        position: 'absolute',
85        inset: 0,
86        width: '100%',
87        height: '100%',
88        overflow: 'visible',
89        backfaceVisibility: 'hidden',
90        transformStyle: 'preserve-3d',
91      });
92
93      function place(direction = 1) {
94        depth = scene.clientWidth / 2;
95
96        gsap.set(turner, {
97          z: -depth,
98          rotationY: 0,
99        });
100
101        gsap.set(front, {
102          transform: `translateZ(${depth}px)`,
103        });
104
105        gsap.set(side, {
106          transform: `
107          rotateY(${direction > 0 ? 90 : -90}deg)
108          translateZ(${depth}px)
109        `,
110        });
111      }
112
113      function runQueue() {
114        if (busy || current === target) return;
115
116        const next = current + Math.sign(target - current);
117
118        rotateTo(next);
119      }
120
121      function rotateTo(next) {
122        if (busy || next === current || next < 0 || next >= count) return;
123
124        const direction = next > current ? 1 : -1;
125
126        busy = true;
127
128        side.append(stores[next]);
129
130        place(direction);
131        setActive(next);
132
133        gsap.set(side, { autoAlpha: 1 });
134        gsap.set(turner, { willChange: 'transform' });
135
136        gsap.to(turner, {
137          rotationY: direction > 0 ? -90 : 90,
138          duration,
139          ease: 'power1.inOut',
140          overwrite: true,
141
142          onComplete() {
143            stores[current].append(...front.childNodes);
144            front.append(...side.childNodes);
145
146            current = next;
147
148            gsap.set(turner, {
149              rotationY: 0,
150              willChange: 'auto',
151            });
152
153            gsap.set(side, {
154              autoAlpha: 0,
155            });
156
157            busy = false;
158
159            runQueue();
160          },
161        });
162      }
163
164      function update(progress) {
165        const normalized = gsap.utils.clamp(0, 1, progress);
166
167        target = Math.round(normalized * (count - 1));
168
169        runQueue();
170      }
171
172      items.forEach((item) => item.classList.remove('is-active'));
173
174      front.append(stores[0]);
175
176      place();
177      setActive(0);
178
179      gsap.set(side, {
180        autoAlpha: 0,
181      });
182
183      const settings = {
184        trigger: wrap,
185        start: 'top top',
186        end: 'bottom bottom',
187        invalidateOnRefresh: true,
188
189        onUpdate(self) {
190          update(self.progress);
191        },
192
193        onRefresh(self) {
194          if (!busy) {
195            place(target < current ? -1 : 1);
196          }
197
198          update(self.progress);
199        },
200      };
201
202      if (desktop) {
203        settings.snap = {
204          snapTo(value) {
205            return Math.round(value * (count - 1)) / (count - 1);
206          },
207          duration: {
208            min: 0.15,
209            max: 0.3,
210          },
211          delay: 0.05,
212          ease: 'power1.inOut',
213          inertia: false,
214        };
215      }
216
217      const scrollTrigger = ScrollTrigger.create(settings);
218
219      update(scrollTrigger.progress);
220    }
221
222    const observer = new IntersectionObserver(
223      (entries) => {
224        if (!entries[0].isIntersecting) return;
225
226        observer.disconnect();
227        initCube();
228      },
229      {
230        rootMargin: '50% 0px',
231      },
232    );
233
234    observer.observe(wrap);
235  });
236</script>

07. Testimonial Image Parallax

Moves testimonial images gently while scrolling.

yPercent and scale to control the movement strength.

1<!-- Parallax effect on testimonial images -->
2<script>
3window.Webflow ||= [];
4window.Webflow.push(() => {
5  if (!window.gsap || !window.ScrollTrigger) return;
6  gsap.registerPlugin(ScrollTrigger);
7  ScrollTrigger.config({ ignoreMobileResize: true });
8
9  gsap.matchMedia().add("(min-width:769px)", () => {
10    gsap.utils.toArray(".parallax-wrap").forEach(wrap => {
11      const img = wrap.querySelector(".parallax-image");
12      if (!img) return;
13
14      gsap.set(wrap, { overflow: "hidden" });
15      gsap.set(img, { yPercent: -15, scale: 1.15 });
16
17      gsap.to(img, {
18        yPercent: 15,
19        ease: "none",
20        scrollTrigger: {
21          trigger: wrap,
22          start: "top 80%",
23          end: "bottom top",
24          scrub: true,
25          invalidateOnRefresh: true,
26          onToggle: self => { img.style.willChange = self.isActive ? "transform" : "auto"; }
27        }
28      });
29    });
30  });
31});
32</script>

08. Process Wheel

Rotates the process wheel and changes the text while scrolling.

stepAngle to control the rotation and scrub to adjust the scroll smoothness.

1<!-- Process wheel on scroll -->
2<script>
3document.addEventListener("DOMContentLoaded", () => {
4  if (
5    typeof gsap === "undefined" ||
6    typeof ScrollTrigger === "undefined"
7  ) return;
8  gsap.registerPlugin(ScrollTrigger);
9  const section = document.querySelector(".process-section");
10  const wheelEl = document.querySelector(".process-wheel");
11  const copies = gsap.utils.toArray(".process-copy");
12  if (!section || !wheelEl || !copies.length) {
13    ScrollTrigger.refresh();
14    return;
15  }
16  const lastIndex = copies.length - 1;
17  const stepAngle = -45;
18  const showOnly = (index) => {
19    gsap.set(copies, { opacity: 0 });
20    gsap.set(copies[index], { opacity: 1 });
21  };
22  if (lastIndex === 0) {
23    showOnly(0);
24    ScrollTrigger.refresh();
25    return;
26  }
27  const mm = gsap.matchMedia();
28  mm.add("(prefers-reduced-motion: reduce)", () => {
29    gsap.set(wheelEl, { rotation: 0 });
30    showOnly(0);
31  });
32  mm.add("(prefers-reduced-motion: no-preference)", () => {
33    gsap.set(wheelEl, { rotation: 0 });
34    showOnly(0);
35    const tl = gsap.timeline({
36      defaults: {
37        ease: "none"
38      },
39      scrollTrigger: {
40        trigger: section,
41        start: "top top",
42        end: () => `+=${lastIndex * window.innerHeight}`,
43        pin: true,
44        anticipatePin: 1,
45        scrub: 0.6,
46        invalidateOnRefresh: true
47      }
48    });
49
50    // One continuous, linear rotation across the whole scroll
51    tl.to(
52      wheelEl,
53      {
54        rotation: stepAngle * lastIndex,
55        duration: lastIndex,
56        ease: "none"
57      },
58      0
59    );
60
61    // Copy crossfades layered on top
62    copies.forEach((_, index) => {
63      if (index === 0) return;
64      const segment = index - 1;
65      tl.to(
66        copies[index - 1],
67        {
68          opacity: 0,
69          duration: 0.3,
70          ease: "sine.out"
71        },
72        segment + 0.3
73      )
74      .to(
75        copies[index],
76        {
77          opacity: 1,
78          duration: 0.3,
79          ease: "sine.out"
80        },
81        segment + 0.55
82      );
83    });
84
85    return () => {
86      tl.scrollTrigger?.kill();
87      tl.kill();
88    };
89  });
90  ScrollTrigger.refresh();
91});
92</script>

09. Service Stacking Cards

Stacks service cards on top of each other while scrolling.

ARRIVE, LINGER, END_HOLD, and FIRST_VH to adjust the timing and first card height.

1<!-- Service Stacking Cards -->
2<script>
3  window.Webflow ||= [];
4  window.Webflow.push(() => {
5    if (typeof gsap === "undefined" || typeof ScrollTrigger === "undefined") return;
6    gsap.registerPlugin(ScrollTrigger);
7    const cards = gsap.utils.toArray(".service-card");
8    if (cards.length < 2) return;
9    const parent = cards[cards.length - 1].parentElement;
10    const ARRIVE = 0.8;
11    const LINGER = 0.1;
12    const END_HOLD = 0.5;
13    const FIRST_VH = 0.7;
14    const MIN_W = 767;
15    const isWide = () => window.innerWidth >= MIN_W;
16    const offsetOf = (el) => parseFloat(getComputedStyle(el).top) || 0;
17    gsap.set(parent, { position: "relative", height: "100vh", overflow: "hidden" });
18    const ps = getComputedStyle(parent);
19    const padL = ps.paddingLeft;
20    const padR = ps.paddingRight;
21    cards.forEach((card, i) => {
22      const off = offsetOf(card);
23      if (i === 0) {
24        gsap.set(card, {
25          position: "absolute",
26          bottom: 0,
27          left: padL,
28          right: padR,
29          height: () => isWide()
30            ? window.innerHeight * FIRST_VH
31            : window.innerHeight - offsetOf(card),
32          margin: 0,
33          overflow: "hidden"
34        });
35      } else {
36        gsap.set(card, {
37          position: "absolute",
38          top: off,
39          left: padL,
40          right: padR,
41          height: `calc(100vh - ${off}px)`,
42          margin: 0,
43          overflow: "hidden"
44        });
45      }
46    });
47    gsap.fromTo(cards[0],
48      {
49        height: () => isWide()
50          ? window.innerHeight * FIRST_VH
51          : window.innerHeight - offsetOf(cards[0])
52      },
53      {
54        height: () => window.innerHeight - offsetOf(cards[0]),
55        ease: "none",
56        scrollTrigger: {
57          trigger: parent,
58          start: "top 30%",
59          end: "top top",
60          scrub: true,
61          invalidateOnRefresh: true
62        }
63      }
64    );
65    const tl = gsap.timeline({
66      scrollTrigger: {
67        trigger: parent,
68        start: "top top",
69        pin: true,
70        scrub: 1,
71        invalidateOnRefresh: true,
72        end: () => "+=" + (
73          (cards.length - 1) * (ARRIVE + LINGER) +
74          END_HOLD
75        ) * window.innerHeight
76      }
77    });
78    cards.forEach((card, i) => {
79      if (i === 0) return;
80      const prevContent = cards[i - 1].querySelector(".service-content");
81      tl.fromTo(card,
82        { y: () => window.innerHeight },
83        { y: 0, duration: ARRIVE, ease: "none" },
84        "+=" + LINGER
85      );
86      if (prevContent) {
87        tl.to(prevContent, {
88          opacity: 0.5,
89          duration: ARRIVE / 2,
90          ease: "none"
91        }, "<" + ARRIVE / 2);
92      }
93    });
94    tl.to({}, { duration: END_HOLD });
95  });
96</script>

SVG Icon Color

This template has some SVG icons. To change the icon color select the icon → Change the Text color in the Style panel. The SVG icon color will update automatically.