1<!-- GSAP Plugin Loader & Error Fallback -->
2
3<script>
4 // Waits for DOM to be ready
5 document.addEventListener("DOMContentLoaded", () => {
6 // Adds a .gsap-not-found class to <html> if GSAP is missing (useful for fallbacks)
7 if (typeof window.gsap === "undefined") document.documentElement.classList.add("gsap-not-found");
8
9 // Registers the required plugins (SplitText and ScrollTrigger)
10 gsap.registerPlugin(ScrollTrigger, SplitText);
11 });
12</script>
13
14<!-- GSAP Text Animation: Reveals words on scroll -->
15<script>
16 document.addEventListener("DOMContentLoaded", () => {
17 // Selects every element with the custom attribute
18 document.querySelectorAll("[data-word-reveal='true']").forEach((text) => {
19
20 // Splits the child elements into words and characters, and adds masking classes
21 const split = SplitText.create(text.children, {
22 type: "words, chars", // Split into words and characters
23 mask: "words", // Only apply masking to words
24 wordsClass: "word", // Add .word class
25 charsClass: "char", // Add .char class
26 });
27
28 // Creates a timeline linked to scroll position
29 const tl = gsap.timeline({
30 scrollTrigger: {
31 trigger: text, // Start animation when this element enters view
32 start: "top bottom", // Animation begins when top of element hits bottom of viewport
33 end: "top 90%", // Ends when top of element reaches 90% of viewport
34 toggleActions: "none play none reset", // Plays only once and resets on scroll back up
35 },
36 });
37
38 // Animates words upward into view
39 tl.from(split.words, {
40 yPercent: 110, // Move each word up from 110%
41 delay: 0, // No delay before starting
42 duration: 0.5, // Each word animates for 0.5s
43 stagger: { amount: 0.1 }, // Stagger all words over 0.1s
44 });
45
46 // Ensures visibility only after animation is applied
47 gsap.set(text, { visibility: "visible" });
48 });
49 });
50</script>
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 3D Hero Image Carousel (Auto-spin + Draggable) -->
2<script>
3{
4document.addEventListener("DOMContentLoaded", () => {
5 gsap.registerPlugin(Draggable, InertiaPlugin);
6
7 // Get the wrapper element with attribute [data-animate="inertia"]
8 const wrapper = document.querySelector("[data-animate='inertia']");
9
10 // Get the container and its child items
11 const items = [...wrapper.querySelector("[data-inertia='item']").children];
12
13 // === CONFIGURATION ===
14 let dragDistancePerRotation = 3000; // Drag distance to complete one full rotation
15 let itemWidth = items[0].offsetWidth; // Width of one item
16 let itemCount = items.length; // Total number of rotating items
17 let radius = (itemWidth / (1.2 * Math.sin(Math.PI / itemCount))) * 1; // Dynamic circular radius
18 const perspective = 5000; // Strength of 3D depth
19 const proxy = document.createElement("div"); // Invisible layer for dragging
20 const progressWrap = gsap.utils.wrap(0, 1); // Ensures progress stays between 0-1
21 let startProgress = 0; // Stores rotation start point
22
23 // Apply 3D perspective to the wrapper
24 gsap.set(wrapper, {
25 perspective: perspective,
26 transformStyle: "preserve-3d",
27 });
28
29 // Set 3D rendering on container
30 const container = wrapper.querySelector("[data-inertia='item']");
31 gsap.set(container, {
32 transformStyle: "preserve-3d",
33 });
34
35 // Auto-spin animation
36 const spin = gsap.fromTo(
37 items,
38 {
39 rotationY: (i) => (i * 360) / items.length, // Evenly space items in a circle
40 z: -radius,
41 },
42 {
43 rotationY: "-=360", // Infinite spin to the left
44 duration: 80, // Very slow loop
45 ease: "none",
46 repeat: -1,
47 transformOrigin: "50% 50% " + -radius + "px",
48 z: -radius,
49 }
50 );
51
52 // Style the proxy element for dragging
53 proxy.style.position = "absolute";
54 proxy.style.width = "100%";
55 proxy.style.height = "100%";
56 proxy.style.top = "0";
57 proxy.style.left = "0";
58 proxy.style.zIndex = "1";
59 proxy.style.cursor = "grab";
60 wrapper.children[0].appendChild(proxy);
61
62 // Draggable config
63 Draggable.create(proxy, {
64 trigger: wrapper,
65 type: "x",
66 inertia: true,
67 onPress() {
68 // Pause the spin on drag
69 gsap.killTweensOf(spin);
70 spin.timeScale(0);
71 startProgress = spin.progress();
72 },
73 onDrag: updateRotation,
74 onThrowUpdate: updateRotation,
75 onRelease() {
76 // Resume spin when released
77 if (!this.tween || !this.tween.isActive()) {
78 gsap.to(spin, { timeScale: 1, duration: 1 });
79 }
80 },
81 onThrowComplete() {
82 gsap.to(spin, { timeScale: 1, duration: 1 });
83 },
84 });
85
86 // Adjust rotation based on drag
87 function updateRotation() {
88 let p = startProgress + (this.startX - this.x) / dragDistancePerRotation;
89 spin.progress(progressWrap(p));
90 }
91
92 // Recalculate item positions on resize
93 function recalculatePositions() {
94 itemWidth = items[0].offsetWidth;
95 itemCount = items.length;
96 radius = (itemWidth / (1 * Math.sin(Math.PI / itemCount))) * 0.8;
97
98 items.forEach((item, i) => {
99 gsap.set(item, {
100 rotationY: (i * 360) / itemCount,
101 z: -radius,
102 });
103 });
104
105 spin.vars.transformOrigin = "50% 50% " + -radius + "px";
106 spin.vars.z = -radius;
107 spin.invalidate(); // Refresh animation
108 gsap.set(wrapper, { perspective: perspective });
109 }
110
111 // Recalculate on window resize
112 window.addEventListener("resize", recalculatePositions);
113
114 // Prevent scrolling while dragging on mobile
115 wrapper.addEventListener(
116 "touchstart",
117 (e) => {
118 if (e.target === wrapper || e.target === proxy) {
119 e.preventDefault();
120 }
121 },
122 { passive: false }
123 );
124 });
125
126 // Ticker: Flip .inner-face when behind
127 gsap.ticker.add(() => {
128 items.forEach((item) => {
129 const face = item.querySelector(".inner-face");
130 if (!face) return;
131
132 const rotationY = gsap.getProperty(item, "rotationY") % 360;
133 const normalized = (rotationY + 360) % 360;
134
135 // Flip inner-face if the item is rotated backward
136 if (normalized > 90 && normalized < 270) {
137 gsap.set(face, { rotationY: 180 });
138 } else {
139 gsap.set(face, { rotationY: 0 });
140 }
141 });
142 });
143}
144</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"
1<script>
2// This code is for the MARQUEE Animations
3
4window.addEventListener("DOMContentLoaded", () => {
5 // Utility to parse attribute values
6 function attr(defaultVal, attrVal) {
7 const defaultValType = typeof defaultVal;
8 if (typeof attrVal !== "string" || attrVal.trim() === "") return defaultVal;
9 if (attrVal === "true" && defaultValType === "boolean") return true;
10 if (attrVal === "false" && defaultValType === "boolean") return false;
11 if (isNaN(attrVal) && defaultValType === "string") return attrVal;
12 if (!isNaN(attrVal) && defaultValType === "number") return +attrVal;
13 return defaultVal;
14 }
15
16 // Run marquee only after Lenis is ready (if you're dispatching the event)
17 const runMarquee = () => {
18 $("[tr-marquee-element='component']").each(function () {
19 const componentEl = $(this);
20 const panelEl = componentEl.find("[tr-marquee-element='panel']");
21 const triggerHoverEl = componentEl.find("[tr-marquee-element='triggerhover']");
22 const triggerClickEl = componentEl.find("[tr-marquee-element='triggerclick']");
23
24 let speedSetting = attr(100, componentEl.attr("tr-marquee-speed"));
25 const verticalSetting = attr(false, componentEl.attr("tr-marquee-vertical"));
26 const reverseSetting = attr(false, componentEl.attr("tr-marquee-reverse"));
27 const scrollDirectionSetting = attr(false, componentEl.attr("tr-marquee-scrolldirection"));
28 const scrollScrubSetting = attr(false, componentEl.attr("tr-marquee-scrollscrub"));
29
30 let moveDistanceSetting = reverseSetting ? 100 : -100;
31 let timeScaleSetting = 1;
32 let pausedStateSetting = false;
33
34 const marqueeTimeline = gsap.timeline({
35 repeat: -1,
36 onReverseComplete: () => marqueeTimeline.progress(1)
37 });
38
39 if (verticalSetting) {
40 speedSetting = panelEl.first().height() / speedSetting;
41 marqueeTimeline.fromTo(panelEl, { yPercent: 0 }, { yPercent: moveDistanceSetting, ease: "none", duration: speedSetting });
42 } else {
43 speedSetting = panelEl.first().width() / speedSetting;
44 marqueeTimeline.fromTo(panelEl, { xPercent: 0 }, { xPercent: moveDistanceSetting, ease: "none", duration: speedSetting });
45 }
46
47 const scrubObject = { value: 1 };
48
49 ScrollTrigger.create({
50 trigger: "body",
51 start: "top top",
52 end: "bottom bottom",
53 onUpdate: (self) => {
54 if (!pausedStateSetting) {
55 if (scrollDirectionSetting && timeScaleSetting !== self.direction) {
56 timeScaleSetting = self.direction;
57 marqueeTimeline.timeScale(self.direction);
58 }
59
60 if (scrollScrubSetting) {
61 let v = gsap.utils.clamp(-3, 3, self.getVelocity() * 0.002);
62 gsap.timeline({
63 onUpdate: () => marqueeTimeline.timeScale(scrubObject.value)
64 }).fromTo(scrubObject, { value: v }, {
65 value: timeScaleSetting,
66 duration: 1.2,
67 ease: "power2.out"
68 });
69 }
70 }
71 }
72 });
73
74 // Pause logic
75 function pauseMarquee(isPausing) {
76 pausedStateSetting = isPausing;
77 const pauseObject = { value: 1 };
78 const pauseTimeline = gsap.timeline({
79 onUpdate: () => marqueeTimeline.timeScale(pauseObject.value)
80 });
81
82 if (isPausing) {
83 pauseTimeline.fromTo(pauseObject, { value: timeScaleSetting }, { value: 0, duration: 0.5 });
84 triggerClickEl.addClass("is-paused");
85 } else {
86 pauseTimeline.fromTo(pauseObject, { value: 0 }, { value: timeScaleSetting, duration: 0.5 });
87 triggerClickEl.removeClass("is-paused");
88 }
89 }
90
91 if (window.matchMedia("(pointer: fine)").matches) {
92 triggerHoverEl.on("mouseenter", () => pauseMarquee(true));
93 triggerHoverEl.on("mouseleave", () => pauseMarquee(false));
94 }
95
96 triggerClickEl.on("click", function () {
97 !$(this).hasClass("is-paused") ? pauseMarquee(true) : pauseMarquee(false);
98 });
99 });
100 };
101 runMarquee();
102});
103</script>
These are custom HTML attributes you add directly to elements in Webflow using the Settings Panel. The script reads them and adjusts the behavior dynamically.
What it does:
Defines the main container for one marquee instance. The script will look inside this element to find everything related to that marquee.
Where to use it:
Apply to the outer wrapper of your marquee section (ex: a Div Block).
What it does:
Defines the element that will move (i.e., your content inside the marquee).
Where to use it:
Apply to the element you want to scroll — typically a div containing repeated text/images. It should be a direct child (or inside) the "component" element.
What it does:
Lets the user pause the marquee on hover (only works on desktop devices).
Where to use it:
Add this to an element that users will hover to pause scrolling (usually the "panel" itself or a child of it).
What it does:
Lets users toggle the marquee pause/resume on click.
Where to use it:
Add to a clickable element (like a pause button or the panel itself). It adds/removes an .is-paused class based on state.
These control how the animation behaves and can be changed without editing the JavaScript:
What it does:
Controls the speed of the marquee (in pixels per second).
Higher = slower, Lower = faster.
Default: 100
Recommended range: 50 – 200
What it does:
Switches the marquee direction to vertical instead of horizontal.
Values:
What it does:
Reverses the direction of the marquee (left → right, or bottom → top).
Values:
What it does:
Enables scroll direction syncing.
The marquee will scroll forward when you scroll down, and reverse when scrolling up.
Values:
What it does:
Makes the marquee speed change dynamically based on your scroll velocity.
Values: