/NOCTURA/
/MENU/
Take advantage of beautiful design and create a website your business deserves.
To make the marquee work, the layout needs to be structured with the correct attributes:
tr-marquee-element="component"
tr-marquee-element="panel"
Inside the Webflow Designer, it looks like this:
Here are the available attributes you can use to control the marquee animation behavior:
tr-marquee-speed
(Number)100
tr-marquee-vertical
(Boolean)true
.tr-marquee-reverse
(Boolean)true
.tr-marquee-scrolldirection
(Boolean)true
to enable.tr-marquee-scrollscrub
(Boolean)<script>
// MARQUEE POWER-UP
window.addEventListener("DOMContentLoaded", (event) => {
// attribute value checker
function attr(defaultVal, attrVal) {
const defaultValType = typeof defaultVal;
if (typeof attrVal !== "string" || attrVal.trim() === "") return defaultVal;
if (attrVal === "true" && defaultValType === "boolean") return true;
if (attrVal === "false" && defaultValType === "boolean") return false;
if (isNaN(attrVal) && defaultValType === "string") return attrVal;
if (!isNaN(attrVal) && defaultValType === "number") return +attrVal;
return defaultVal;
}
// marquee component
$("[tr-marquee-element='component']").each(function (index) {
let componentEl = $(this),
panelEl = componentEl.find("[marquee-element='panel']");
let speedSetting = attr(100, componentEl.attr("marquee-speed")),
verticalSetting = attr(false, componentEl.attr("marquee-vertical")),
reverseSetting = attr(false, componentEl.attr("marquee-reverse")),
scrollDirectionSetting = attr(false, componentEl.attr("marquee-scrolldirection")),
scrollScrubSetting = attr(false, componentEl.attr("marquee-scrollscrub")),
moveDistanceSetting = reverseSetting ? 100 : -100,
timeScaleSetting = 1;
let marqueeTimeline = gsap.timeline({ repeat: -1, onReverseComplete: () => marqueeTimeline.progress(1) });
if (verticalSetting) {
speedSetting = panelEl.first().height() / speedSetting;
marqueeTimeline.fromTo(panelEl, { yPercent: 0 }, { yPercent: moveDistanceSetting, ease: "none", duration: speedSetting });
} else {
speedSetting = panelEl.first().width() / speedSetting;
marqueeTimeline.fromTo(panelEl, { xPercent: 0 }, { xPercent: moveDistanceSetting, ease: "none", duration: speedSetting });
}
let scrubObject = { value: 1 };
ScrollTrigger.create({
trigger: "body",
start: "top top",
end: "bottom bottom",
onUpdate: (self) => {
if (scrollDirectionSetting) {
timeScaleSetting = self.direction;
marqueeTimeline.timeScale(self.direction);
}
if (scrollScrubSetting) {
let v = self.getVelocity() * 0.006;
v = gsap.utils.clamp(-60, 60, v);
let scrubTimeline = gsap.timeline({ onUpdate: () => marqueeTimeline.timeScale(scrubObject.value) });
scrubTimeline.fromTo(scrubObject, { value: v }, { value: timeScaleSetting, duration: 0.5 });
}
}
});
});
});
</script>
You can control how each text block animates by using these attributes:
data-animate="text"
data-type="chars"
"words"
or "lines"
.data-duration="1.2"
data-delay="2"
data-start="hidden"
autoAlpha: 1
at start.)<script>
{
let lenis;
const initScroll = () => {
lenis = new Lenis({});
lenis.on("scroll", ScrollTrigger.update);
gsap.ticker.add((time) => lenis.raf(time * 1000));
gsap.ticker.lagSmoothing(0);
};
function initGsapGlobal() {
/** Do everything that needs to happen
* before triggering all
* the gsap animations */
initScroll();
// match reduced motion media
// const media = gsap.matchMedia();
/** Send a custom
* event to all your
* gsap animations
* to start them */
const sendGsapEvent = () => {
window.dispatchEvent(
new CustomEvent("GSAPReady", {
detail: {
lenis,
},
})
);
};
// Check if fonts are already loaded
if (document.fonts.status === "loaded") {
sendGsapEvent();
} else {
document.fonts.ready.then(() => {
sendGsapEvent();
});
}
/** We need specific handling because the
* grid/list changes the scroll height of the whole container
*/
let resizeTimeout;
const onResize = () => {
clearTimeout(resizeTimeout);
resizeTimeout = setTimeout(() => {
ScrollTrigger.refresh();
}, 50);
};
window.addEventListener("resize", () => onResize());
const resizeObserver = new ResizeObserver((entries) => onResize());
resizeObserver.observe(document.body);
queueMicrotask(() => {
gsap.to("[data-start='hidden']", {
autoAlpha: 1,
duration: 0.1,
delay: 0.2,
});
});
}
// this only for dev
const documentReady =
document.readyState === "complete" || document.readyState === "interactive";
if (documentReady) {
initGsapGlobal();
} else {
addEventListener("DOMContentLoaded", (event) => initGsapGlobal());
}
}
</script>
<script>
{
/** Utility Functions */
const split = (text, type = "chars") => {
text.setAttribute("aria-label", text.textContent);
const splits = new SplitText(text, { type });
splits[type].forEach((char) => char.setAttribute("aria-hidden", "true"));
return splits[type];
};
const getParams = (item) => {
const { duration, delay, stagger, ease, type } = item.dataset;
const params = {
duration: 1.2,
delay: 0,
type: "chars",
};
if (duration) params.duration = parseFloat(duration);
if (delay) params.delay = parseFloat(delay);
if (type && (type === "chars" || type === "words" || type === "lines"))
params.type = type;
return params;
};
const baseAnimation = (item) => {
const params = getParams(item);
return {
duration: params.duration,
delay: params.delay,
ease: "expo.out",
// stagger each item so the letters come in one after the other
stagger: {
each: 0.04,
from: "start",
},
scrollTrigger: {
trigger: item,
// use scrolltrigger to trigger on enter and reset on exit
toggleActions: "play none none none",
},
};
};
/** (1) Split Reveal Animation */
function createSplitRevealAnimation(item) {
const params = getParams(item); // 1. get params from attributes
const text = split(item, params.type); // 2. split the text
// 3. set container to overflow hidden
item.style.overflow = "hidden";
// 4. For words and lines, create proper mask containers
if (params.type === "words" || params.type === "lines") {
text.forEach((element) => {
// Create inner wrapper for animation
const inner = document.createElement('div');
inner.innerHTML = element.innerHTML;
inner.style.display = 'inline-block';
// Set up outer wrapper as mask
element.style.display = 'inline-block';
element.style.overflow = 'hidden';
element.style.verticalAlign = 'top';
element.innerHTML = '';
element.appendChild(inner);
});
// Animate the inner elements instead
const innerElements = text.map(el => el.children[0]);
return gsap.fromTo(
innerElements,
{ yPercent: 120 },
{
...baseAnimation(item),
yPercent: 0,
}
);
}
// 5. For chars, animate normally
return gsap.fromTo(
text,
{ yPercent: 120 },
{
...baseAnimation(item),
yPercent: 0,
}
);
}
function createScrambledAnimation(item) {
const text = item.textContent;
const randomText = Array(text.length)
.fill()
.map(() => String.fromCharCode(Math.floor(Math.random() * (90 - 65) + 65)))
.join("")
.toLowerCase();
return gsap.fromTo(
item.children[0],
{
scrambleText: randomText,
},
{
...baseAnimation(item),
scrambleText: text,
chars: text,
}
);
}
/** Main Text Effect Wrapper */
addEventListener("GSAPReady", (event) => {
const texts = [...document.querySelectorAll('[data-animate="text"]')];
texts.forEach((item) => {
if (item.dataset.type === "chars") createSplitRevealAnimation(item);
else if (item.dataset.type === "words") createSplitRevealAnimation(item);
else if (item.dataset.type === "scramble") createScrambledAnimation(item);
});
});
}
</script>