1<!-- Lenis Smooth Scroll -->
2
3<script src="https://unpkg.com/lenis@1.3.1/dist/lenis.min.js"></script>
4
5<script>
6document.addEventListener("DOMContentLoaded", () => {
7 // Register necessary plugins (required for validation)
8 gsap.registerPlugin(ScrollTrigger);
9
10 // Initialize Lenis
11 const lenis = new Lenis({
12 duration: 1.2,
13 easing: (t) => Math.min(1, 1.001 - Math.pow(2, -10 * t)), // expo.out
14 smooth: true,
15 smoothTouch: false,
16 touchMultiplier: 2
17 });
18
19 // Sync ScrollTrigger with Lenis
20 lenis.on("scroll", ScrollTrigger.update);
21
22 // Use GSAP's ticker to drive Lenis — this is required to pass GSAP validation
23 gsap.ticker.add((time) => {
24 lenis.raf(time * 1000);
25 });
26
27 // Optional: Fire custom event to trigger delayed animations
28 window.dispatchEvent(new CustomEvent("GSAPReady", {
29 detail: { lenis }
30 }));
31});
32</script>1<!-- GSAP Trail Image Effect -->
2
3<style>
4 [fc-trail-image=list] img {
5 opacity: 0;
6 position: absolute;
7 will-change: transform;
8 pointer-events: none;
9 max-width: none;
10 }
11</style>
12
13<script>
14// Utility functions
15const MathUtils = {
16 lerp: (a, b, n) => (1 - n) * a + n * b,
17 distance: (x1, y1, x2, y2) => Math.hypot(x2 - x1, y2 - y1)
18};
19
20// Get mouse position relative to the trail container
21const getMousePos = (e, container) => {
22 const rect = container.getBoundingClientRect();
23 return {
24 x: e.clientX - rect.left,
25 y: e.clientY - rect.top
26 };
27};
28
29// Represents a single image in the trail
30class Image {
31 constructor(el) {
32 this.DOM = { el };
33 this.defaultStyle = {
34 scale: 1,
35 x: 0,
36 y: 0,
37 opacity: 0
38 };
39 this.getRect();
40 this.initEvents();
41 }
42
43 initEvents() {
44 window.addEventListener('resize', () => this.resize());
45 }
46
47 resize() {
48 gsap.set(this.DOM.el, this.defaultStyle);
49 this.getRect();
50 }
51
52 getRect() {
53 this.rect = this.DOM.el.getBoundingClientRect();
54 }
55
56 isActive() {
57 return gsap.isTweening(this.DOM.el) || this.DOM.el.style.opacity != 0;
58 }
59}
60
61// Controls the image trail behavior
62class ImageTrail {
63 constructor(list, mouseThreshold, opacityFrom, scaleFrom, opacityTo, scaleTo, mainDuration, mainEase, fadeOutDuration, fadeOutDelay, fadeOutEase, resetIndex, resetIndexDelay) {
64 this.DOM = { content: list };
65 this.images = [];
66 [...this.DOM.content.querySelectorAll('img')].forEach(img => this.images.push(new Image(img)));
67 this.imagesTotal = this.images.length;
68 this.imgPosition = 0;
69 this.zIndexVal = 1;
70
71 // ✅ Attribute-based values from Webflow
72 this.threshold = isNaN(mouseThreshold) ? 100 : mouseThreshold; // `fc-trail-image-threshold`
73 this.opacityFrom = isNaN(opacityFrom) ? 0.6 : opacityFrom; // `fc-trail-image-opacity-from`
74 this.scaleFrom = isNaN(scaleFrom) ? 0.8 : scaleFrom; // `fc-trail-image-scale-from`
75 this.opacityTo = isNaN(opacityTo) ? 1 : opacityTo; // `fc-trail-image-opacity-to`
76 this.scaleTo = isNaN(scaleTo) ? 1 : scaleTo; // `fc-trail-image-scale-to`
77 this.mainDuration = isNaN(mainDuration) ? 0.7 : mainDuration; // `fc-trail-image-main-duration`
78 this.mainEase = mainEase === null ? 'power3' : mainEase; // `fc-trail-image-main-ease`
79 this.fadeOutDuration = isNaN(fadeOutDuration) ? 1 : fadeOutDuration; // `fc-trail-image-fade-out-duration`
80 this.fadeOutDelay = isNaN(fadeOutDelay) ? 0.3 : fadeOutDelay; // `fc-trail-image-fade-out-delay`
81 this.fadeOutEase = fadeOutEase === null ? 'power3' : fadeOutEase; // `fc-trail-image-fade-out-ease`
82 this.resetIndex = resetIndex === null ? "false" : resetIndex; // `fc-trail-image-reset-index`
83 this.resetIndexDelay = isNaN(resetIndexDelay) ? 200 : resetIndexDelay; // `fc-trail-image-reset-index-delay`
84
85 this.mousePos = { x: 0, y: 0 };
86 this.lastMousePos = { x: 0, y: 0 };
87 this.cacheMousePos = { x: 0, y: 0 };
88 this.frameCount = 0;
89 this.stopAnimationFrame = false;
90 }
91
92 render() {
93 const distance = MathUtils.distance(this.mousePos.x, this.mousePos.y, this.lastMousePos.x, this.lastMousePos.y);
94 this.cacheMousePos.x = MathUtils.lerp(this.cacheMousePos.x || this.mousePos.x, this.mousePos.x, 0.1);
95 this.cacheMousePos.y = MathUtils.lerp(this.cacheMousePos.y || this.mousePos.y, this.mousePos.y, 0.1);
96
97 if (distance > this.threshold) {
98 this.showNextImage();
99 ++this.zIndexVal;
100 this.imgPosition = this.imgPosition < this.imagesTotal - 1 ? this.imgPosition + 1 : 0;
101 this.lastMousePos = this.mousePos;
102 }
103
104 let isIdle = this.images.every((img) => !img.isActive());
105
106 if (isIdle) {
107 this.frameCount++;
108 if (this.resetIndex === "true" && this.frameCount >= this.resetIndexDelay) {
109 this.frameCount = 0;
110 this.imgPosition = 0;
111 }
112 if (this.zIndexVal !== 1) {
113 this.zIndexVal = 1;
114 }
115 }
116
117 if (!this.stopAnimationFrame) requestAnimationFrame(() => this.render());
118 }
119
120 showNextImage() {
121 const img = this.images[this.imgPosition];
122 gsap.killTweensOf(img.DOM.el);
123
124 gsap.timeline()
125 .set(img.DOM.el, {
126 opacity: this.opacityFrom,
127 scale: this.scaleFrom,
128 zIndex: this.zIndexVal,
129 x: this.cacheMousePos.x - img.rect.width / 2,
130 y: this.cacheMousePos.y - img.rect.height / 2
131 })
132 .to(img.DOM.el, {
133 ease: this.mainEase,
134 x: this.mousePos.x - img.rect.width / 2,
135 y: this.mousePos.y - img.rect.height / 2,
136 opacity: this.opacityTo,
137 scale: this.scaleTo,
138 duration: this.mainDuration
139 })
140 .to(img.DOM.el, {
141 ease: this.fadeOutEase,
142 opacity: 0,
143 scale: this.scaleFrom,
144 duration: this.fadeOutDuration,
145 delay: this.fadeOutDelay,
146 onComplete: () => {
147 // Reset image style for reuse
148 gsap.set(img.DOM.el, {
149 x: 0,
150 y: 0,
151 scale: 1,
152 opacity: 0,
153 zIndex: 1
154 });
155 }
156 });
157 }
158}
159
160// Initialize after DOM is ready
161document.addEventListener("DOMContentLoaded", () => {
162 requestAnimationFrame(() => {
163 const components = document.querySelectorAll('[fc-trail-image=component]');
164 let imageTrails = [];
165
166 components.forEach((component, i) => {
167 const list = component.querySelector('[fc-trail-image=list]');
168 const mouseThreshold = parseInt(component.getAttribute('fc-trail-image-threshold'));
169 const opacityFrom = parseFloat(component.getAttribute('fc-trail-image-opacity-from'));
170 const scaleFrom = parseFloat(component.getAttribute('fc-trail-image-scale-from'));
171 const opacityTo = parseFloat(component.getAttribute('fc-trail-image-opacity-to'));
172 const scaleTo = parseFloat(component.getAttribute('fc-trail-image-scale-to'));
173 const mainDuration = parseFloat(component.getAttribute('fc-trail-image-main-duration'));
174 const mainEase = component.getAttribute('fc-trail-image-main-ease');
175 const fadeOutDuration = parseFloat(component.getAttribute('fc-trail-image-fade-out-duration'));
176 const fadeOutDelay = parseFloat(component.getAttribute('fc-trail-image-fade-out-delay'));
177 const fadeOutEase = component.getAttribute('fc-trail-image-fade-out-ease');
178 const resetIndex = component.getAttribute('fc-trail-image-reset-index');
179 const resetIndexDelay = parseInt(component.getAttribute('fc-trail-image-reset-index-delay'));
180
181 // Track mouse inside component
182 component.addEventListener('mousemove', function (ev) {
183 if (imageTrails[i].resetIndex === "true" && imageTrails[i].frameCount > 0)
184 imageTrails[i].frameCount = 0;
185 imageTrails[i].mousePos = getMousePos(ev, component);
186 });
187
188 // Start animation on hover
189 component.addEventListener("mouseenter", () => {
190 imageTrails[i].stopAnimationFrame = false;
191 requestAnimationFrame(() => imageTrails[i].render());
192
193 if (imageTrails[i].resetIndex === "true") {
194 imageTrails[i].imgPosition = 0;
195 imageTrails[i].frameCount = 0;
196 }
197 });
198
199 // Stop animation on mouse leave
200 component.addEventListener("mouseleave", () => {
201 imageTrails[i].stopAnimationFrame = true;
202 });
203
204 // Create instance of ImageTrail
205 imageTrails.push(new ImageTrail(
206 list,
207 mouseThreshold,
208 opacityFrom,
209 scaleFrom,
210 opacityTo,
211 scaleTo,
212 mainDuration,
213 mainEase,
214 fadeOutDuration,
215 fadeOutDelay,
216 fadeOutEase,
217 resetIndex,
218 resetIndexDelay
219 ));
220 });
221 });
222});
223</script>Attributes
fc-trail-image="component"
*Required
Defines the entire interactive image trail wrapper
fc-trail-image="list"
*Required
The wrapper that contains all the <img> elements to animate
fc-trail-image-threshold
Optional
Minimum mouse movement (in px) before next image is triggered
fc-trail-image-opacity-from
Optional
Starting opacity value for each image (e.g. 0.6)
fc-trail-image-scale-from
Optional
Starting scale value for each image (e.g. 0.8)
fc-trail-image-opacity-to
Optional
Final opacity value when image appears (e.g. 1)
fc-trail-image-scale-to
Optional
Final scale value when image appears (e.g. 1)
fc-trail-image-main-duration
Optional
Duration (in seconds) of the image entering animation
fc-trail-image-main-ease
Optional
GSAP easing function (e.g. power3, sine.inOut)
fc-trail-image-fade-out-duration
Optional
How long the image takes to fade out
fc-trail-image-fade-out-delay
Optional
How long to wait before fading out
fc-trail-image-fade-out-ease
Optional
GSAP easing function for the fade out
fc-trail-image-reset-index
Optional
"true" resets image index when idle or on mouse enter
fc-trail-image-reset-index-delay
Optional
Number of frames before reset happens if reset-index is "true"