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.
.png)
.png)
