Horeca began as a easy touchdown web page, however shortly advanced into one thing way more bold. Textual content that scales as much as fill the viewport and stays pivoted on one particular character. Accordion headers that stick with the underside of the viewport as a substitute of the highest. A feed part the place playing cards come out from deep z-depth towards the digicam. A stack of playing cards, a hover mega menu, and all of it wanted to work on cellular with out breaking. On the time, reaching this degree of movement design required a mix of Webflow and customized GSAP, which ultimately grew into round 2,000 traces of animation code.
The half most case research skip is the modifying facet. It’s simple to construct a heavy animation web site for those who don’t care what occurs when the shopper opens the Designer to vary a headline. Stack some absolute positioned divs, bury parts 10 layers deep, hardcode pixel values, accomplished. Seems nice within the case examine, but it surely’s a nightmare in manufacturing. So Webflow gave us the CMS and the builder, Lumos gave us the category system and fluid items so responsiveness stayed inline as we constructed, and GSAP prolonged what was doable past Webflow’s interplay capabilities on the time.
On this case examine I need to stroll by the components that took longest and broke essentially the most. The scaling textual content pivot math, the bottom-sticky accordion, the feed depth animation, the Lenis on cellular catastrophe, and the in app browser hell. At present, many of those interactions will be constructed instantly in Webflow utilizing visible GSAP timelines, considerably lowering the quantity of customized code required.
Tech Stack
- Webflow because the CMS and visible builder
- Lumos framework for the category system, fluid items, and element construction
- GSAP because the animation core
- ScrollTrigger for every thing scroll-bound
- SplitText for line, phrase, and character splits
- Lenis for easy scroll on desktop solely (extra on why “desktop solely” later)
- Customized CSS variables for all theming, spacing, and the accordion math
No construct step. No bundler. All the things ships as inline customized code inside Webflow’s mission settings and embeds. That constraint shapes a number of the selections beneath.
Two Scroll Suggestions That Saved This Venture
Tip 1: Simply disable Lenis on cellular
Lenis is nice on desktop. On cellular it was a catastrophe — glitchy scroll, jittery sticky sections, pinned parts desyncing from their triggers.
I spent method too lengthy making an attempt to patch it. Tweaked configs, chased edge instances, overcomplicated the entire detection layer. None of it labored. The precise repair was the boring one: don’t run Lenis on cellular in any respect.
window.isMobile = perform () {
if (navigator.userAgentData && navigator.userAgentData.cellular !== undefined) {
return navigator.userAgentData.cellular;
}
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.take a look at(
navigator.userAgent
);
};
if (!isMobile()) {
lenis = new Lenis({ /* ...config */ });
lenis.on("scroll", ScrollTrigger.replace);
gsap.ticker.add((time) => lenis.raf(time * 1000));
}
Lesson: if the library is combating the platform, cease patching and simply flip it off the place it doesn’t belong.
Tip 2: Use a customized scroller div as a substitute of the physique (for iOS)
On iOS, the deal with bar collapsing and increasing on scroll resizes the viewport. Each time that occurs, ScrollTrigger recalculates and your fastidiously timed timelines reset or drift. You see this on most animation-heavy websites, the scroll feels fallacious and animations re-trigger out of nowhere.
The repair is to take scrolling away from the physique and provides it to a fixed-height container as a substitute. The physique will get overflow: hidden, and a .page_wrap div turns into the precise scroll area. No deal with bar interplay = no viewport jitter = secure triggers.
const MOBILE_SCROLLER = ".page_wrap";
perform getScrollContainer()
ScrollTrigger.defaults({ scroller: getScrollContainer() });
Caveats for those who do that:
- You’re now managing scroll your self. Something that anticipated
windowbecause the scroller must be pointed at your container. - Anchor hyperlinks (
href="#part") cease understanding of the field. It’s a must to intercept these clicks and run your personal scroll-to logic towards the customized container. - Something that reads
window.scrollYmust learn out of your container’sscrollTopas a substitute.
It’s extra wiring, however the payoff is rock-solid ScrollTrigger timelines on iOS.
The Scaling Textual content Sections
The design referred to as for a textual content block the place one a part of the road scales massively whereas the encompassing textual content strikes away vertically, and the scaling phrase stays pivoted on a particular character. Two cases of this on the web page.
The naive method is to scale the entire textual content ingredient. That works visually for a half second after which the complete web page reflows as a result of a rework: scale(50) on a textual content ingredient drags the doc top into the stratosphere if the dad or mum isn’t constrained. Efficiency dies, ScrollTrigger recalculates, and the web page jitters.
The precise method: scale by way of rework solely, pin the part with CSS sticky, management progress by way of a single ScrollTrigger, and pre-measure the pivot offset so we will translate the scaled ingredient again to maintain the pivot character at viewport heart.
perform initializeLongScrollAnimation(longScrollSection, index) {
const stickyContent = longScrollSection.querySelector(
"[data-gsap-state='pinned']"
);
const textTop = longScrollSection.querySelector("[data-gsap-text='top']");
const textMiddle = longScrollSection.querySelector(
"[data-gsap-text='middle']"
);
const textBottom = longScrollSection.querySelector(
"[data-gsap-text='bottom']"
);
const pivotElement = textMiddle?.querySelector(
"[data-gsap-pivot='pivot']"
);
let pivotOffsetX = 0;
if (textMiddle && pivotElement) {
const textMiddleRect = textMiddle.getBoundingClientRect();
const pivotRect = pivotElement.getBoundingClientRect();
const textMiddleCenterX =
textMiddleRect.left + textMiddleRect.width / 2;
const pivotCenterX =
pivotRect.left + pivotRect.width / 2 + pivotRect.width * 0.1;
pivotOffsetX = pivotCenterX - textMiddleCenterX;
gsap.set(textMiddle, {
scale: 0,
transformOrigin: "50% 50%",
});
}
ScrollTrigger.create({
set off: longScrollSection,
begin: "high high",
finish: "backside backside",
scrub: !isMobile() ? true : 1,
onUpdate: (self) => {
const progress = self.progress;
const progress1 = Math.min(progress / 0.6, 1);
const progress2 = !isMobile()
? progress >= 0.3
? (progress - 0.3) / 0.55
: 0
: progress >= 0.45
? (progress - 0.45) / 0.2
: 0;
if (textTop) {
gsap.set(textTop, { y: `${progress1 * -100}%` });
}
if (textBottom) {
gsap.set(textBottom, { y: `${progress1 * 100}%` });
}
if (textMiddle && pivotElement) {
const currentScale = !isMobile()
? Math.max(0, progress1 * 2.25)
: Math.max(0, progress1 * 2.95);
const scaledPivotOffset = pivotOffsetX * currentScale;
const targetTranslateX = -scaledPivotOffset;
const middleOpacity = Math.min(
Math.max((progress1 - 0) / 0.33, 0),
1
);
gsap.set(textMiddle, {
scale: currentScale,
x: targetTranslateX,
transformOrigin: "50% 50%",
opacity: middleOpacity,
});
}
},
});
}
Three issues matter in that code:
- One ScrollTrigger, a number of progress derivations. As a substitute of stacking three or 4 ScrollTriggers with overlapping begin/finish values, now we have a single set off that drives the entire part and computes
progress1,progress2, andprogress3as derived ranges. That is method cheaper than letting ScrollTrigger calculate three separate positions each body. - Pre-measured pivot offset. We measure the place the pivot character sits relative to the scaling ingredient’s heart precisely as soon as, at setup. Then on each scroll replace we multiply that offset by the present scale and translate the ingredient by the destructive, which retains the pivot character pinned to viewport heart as the remainder of the textual content grows round it.
gsap.setas a substitute ofgsap.toinsideonUpdate. That is non-obvious however essential. Inside a scrubbed ScrollTrigger, you’re firing on each scroll occasion.gsap.tocreates a tween occasion each body;gsap.setwrites on to the ingredient. For scroll-driven animations the distinction provides up quick.
The cellular variant scales more durable (2.95x vs 2.25x) as a result of the viewport is narrower and the scaling phrase must really feel simply as dominant.
The Stack of Playing cards (Sticky Slides)
The design had a stack of huge playing cards that pin one after one other on the high of the viewport, with the lively card scaling and rotating barely as the following one is available in. Playing cards then fade out as you scroll previous, like they’re being shuffled to the underside of a deck.
The intuition right here is to make use of GSAP pin for every card. We tried that. It labored, however each pin provides a ScrollTrigger pin-spacer to the DOM, the structure math will get difficult on resize, and on cellular the pinning would desync when the deal with bar collapsed.
We changed GSAP pinning with CSS place: sticky on every .slide-wrapper, then used GSAP just for the rotation, scale, and fade animations:
const cardsWrappers = gsap.utils.toArray(".slide-wrapper").slice(0, -1);
const playing cards = gsap.utils.toArray(".card_stack_component");
cardsWrappers.forEach((wrapper, i) => {
const card = playing cards[i];
gsap.to(card, {
rotationZ: (Math.random() - 0.5) * 10,
scale: 0.7,
rotationX: 40,
ease: "none",
scrollTrigger: {
set off: wrapper,
begin: "high high",
finish: "backside heart",
endTrigger: ".g_component_layout",
scrub: !isMobile() ? true : 1,
},
});
gsap.to(card, {
autoAlpha: 0,
ease: "power1.in",
scrollTrigger: {
set off: card,
begin: "high -80%",
finish: "+=" + 0.2 * window.innerHeight,
scrub: !isMobile() ? true : 1,
},
});
});
The CSS sticky does all of the pinning work. GSAP simply handles the visible rework. Efficiency jumped dramatically as soon as we moved away from pin: true right here.
The Accordion That Sticks to the Backside
This was the trickiest structure puzzle on the mission, and it has the only resolution.
The design referred to as for an accordion the place every header, as you scroll, snaps to the underside of the viewport as a substitute of the highest. In order you scroll down, accordion gadgets stack from the underside up. Most sticky implementations stick with the highest as a result of that’s what place: sticky; high: 0 does. There’s no equal backside: 0 conduct that performs properly inside a scrolling dad or mum, particularly when gadgets are stacked vertically.
The trick was to place the headers completely after the primary render and use CSS customized properties to calculate the place every one ought to land:
const accordionContainer = doc.querySelector('[data-gsap="inview"]');
const accordionHeaders = doc.querySelectorAll(".accordion_header");
const accordionWrapper = doc.querySelector(
'[data-gsap="accordion-wrapper"]'
);
let headerHeight = "8rem";
if (accordionContainer && accordionHeaders.size > 0 && accordionWrapper) {
const totalItemsCount = accordionHeaders.size;
const sectionHeight = accordionContainer.getBoundingClientRect().top;
const wrapperHeight = accordionWrapper.offsetHeight;
const headerHeightPx = !isMobile()
? `${wrapperHeight / totalItemsCount}px`
: headerHeight;
const sectionHeightPx = `${sectionHeight}px`;
doc.documentElement.model.setProperty(
"--total-items",
totalItemsCount
);
doc.documentElement.model.setProperty(
"--section-height",
sectionHeightPx
);
doc.documentElement.model.setProperty(
"--header-height",
headerHeightPx
);
accordionHeaders.forEach((header, index) => {
const itemPosition = index + 1;
header.model.setProperty("--item-position", itemPosition);
if (!isMobile()) {
setTimeout(() => {
header.model.place = "absolute";
}, 1000);
}
});
}
The JS does virtually nothing. It writes 4 CSS variables to :root and one variable per header (--item-position). The precise stickiness occurs within the stylesheet utilizing calculations towards these variables. On desktop, the headers go to place: absolute as soon as the maths has been written, and CSS handles the layered bottom-stacking from there.
The entire impact runs on a single ScrollTrigger that simply toggles an .inview class:
ScrollTrigger.create({
set off: accordionWrapper,
begin: `high bottom-=${wrapperHeight}`,
onEnter: () => {
accordionContainers.forEach((container) => {
container.classList.add("inview");
});
if (!isMobile()) {
refreshScrollTriggers();
}
},
onLeaveBack: () => {
accordionContainers.forEach((container) => {
container.classList.take away("inview");
});
},
});
The lesson: each time we reached for JavaScript animation on this mission, we requested first whether or not CSS might do it cheaper. More often than not, it might.
The Feed Reveal (Playing cards Rising from the Void)
This part was a part of the unique construct however was later eliminated on the shopper’s request. Preserving the breakdown right here as a result of it was one of many trickier efficiency issues on the mission.
The footer part had a stack of feed gadgets that exposed as you scrolled, coming from deep z-depth towards the digicam. The closest merchandise to z=1 turned the “lively” one and triggered a corresponding background picture to fade in.
This was essentially the most performance-sensitive animation on the web page as a result of it ran on each scroll body and touched each card concurrently. Early variations used gsap.to contained in the replace loop, which created tons of of micro-tweens per second. The repair was disciplined use of gsap.set and caching each queried ingredient.
class FeedItemsAnimation {
constructor(container) {
this.container = container;
this.feedItems = [...container.querySelectorAll(".feed_cms_item")];
if (this.feedItems.size === 0) {
console.warn("No feed gadgets discovered - skipping feed animation");
return;
}
this.feedScrollWrapper = container.querySelector(".feed_scroll_wrapper");
this.feedList = container.querySelector(".feed_cms_list");
// Cache feed pictures as soon as as a substitute of querying each body
this.feedImages = this.feedItems.map((merchandise) =>
merchandise.querySelector(".feed_img")
);
this.bgItems = [...container.querySelectorAll(".feed_bg-content-item")];
this.bgContainer = container.querySelector(".feed_bg-content");
this.isMobile = isMobile();
this.scrollerHeight = this.isMobile
? currentScroller && currentScroller !== window
? currentScroller.clientHeight
: window.innerHeight
: window.innerHeight;
this.targetZValue = 1;
this.numItems = this.feedItems.size;
this.zDepthConfig = {
desktop: { initialSpacing: -1800, totalRange: 3000, maxOffset: 1800 * this.numItems },
cellular: { initialSpacing: -900, totalRange: 1500, maxOffset: 900 * this.numItems },
};
this.currentConfig = this.isMobile
? this.zDepthConfig.cellular
: this.zDepthConfig.desktop;
this.init();
}
getProgress = () => {
this.resetClosestItem();
this.feedItems.forEach((merchandise, index) => {
const z = gsap.getProperty(merchandise, "z");
const normalizedZ = gsap.utils.normalize(
-this.currentConfig.totalRange,
0,
z
);
merchandise.dataset.z = normalizedZ;
// gsap.set as a substitute of gsap.to - no tween creation per body
gsap.set(merchandise, { opacity: normalizedZ + 0.2 });
const itemImage = this.feedImages[index];
if (itemImage) {
const scaleMultiplier = this.isMobile ? 0.6 : 0.5;
const baseScale = this.isMobile ? 0.8 : 0.75;
gsap.set(itemImage, {
scale: normalizedZ * scaleMultiplier + baseScale,
});
}
const zDifference = Math.abs(normalizedZ - this.targetZValue);
if (zDifference < this.closestZDifference) {
this.closestZDifference = zDifference;
this.closestItem = merchandise;
}
});
const newIndex = this.feedItems.indexOf(this.closestItem);
if (newIndex !== this.currIndex) {
this.handleBackgroundTransition(newIndex);
this.currIndex = newIndex;
}
};
}
4 optimizations made this performant:
- Cache
.feed_imgreferences at building. The sooner model queried each card’s picture each body. gsap.setfor per-frame updates,gsap.tojust for the one-off background fade. The background swap occurs as soon as per “lively card” change, not each body, sotois okay there.- Scrub 0.3, not 0.1. Decrease scrub values really feel responsive however they hearth extra ceaselessly and burn CPU. 0.3 appears similar to the attention and prices noticeably much less.
- CSS
place: stickyonce more as a substitute of GSAPpin. Similar reasoning as the cardboard stack.
Cellular makes use of tighter z-spacing (-900 vs -1800) as a result of the smaller viewport means playing cards visually fill the display sooner, so that they don’t want as a lot depth vary to really feel like they’re “rising from the void.”
The Mega Menu
The navigation has a hover-expanded mega menu. The set off button morphs right into a multi-link panel: set off textual content staggers out character-by-character, the set off collapses, and hyperlink phrases stagger in from beneath. Reverse on mouseleave.
The problem wasn’t the animation itself. It was state administration. Hover-driven timelines get into hassle quick for those who don’t kill the earlier timeline earlier than beginning a brand new one. Fast mouseenter/mouseleave/mouseenter sequences trigger overlapping tweens, and characters find yourself caught in mid-animation.
let hoverInTl = null;
let hoverOutTl = null;
let isOpen = false;
perform openMenu() {
if (isOpen) return;
isOpen = true;
if (hoverInTl) hoverInTl.kill();
if (hoverOutTl) hoverOutTl.kill();
hoverInTl = gsap.timeline({
onComplete: () => { hoverInTl = null; },
});
hoverInTl
.to(triggerSplit.chars, {
yPercent: 100,
opacity: 0,
length: 0.35,
ease: "Quart.easeIn",
stagger: 0.05,
}, 0)
.to(triggerIcon, {
x: 100,
opacity: 0,
length: 0.35,
ease: "Quart.easeIn",
}, 0);
hoverInTl
.set(navMenuTrigger, { place: "absolute" }, 0)
.set(navMenuMask, { place: "relative", show: "flex" }, 0);
hoverInTl.to(navMenuMask, {
width: "auto",
opacity: 1,
pointerEvents: "auto",
length: 0.5,
ease: "Quart.easeInOut",
}, 0.15);
linkSplits.forEach((splitData, index) => {
hoverInTl.to(splitData.break up.phrases, {
yPercent: 0,
opacity: 1,
length: 0.35,
ease: "Again.easeOut",
stagger: -0.03,
}, 0.25 + index * 0.05);
});
}
The sample: an isOpen flag prevents redundant calls, the open and shut timelines kill one another on entry, and the timeline holds its personal reference till completion at which level it nulls itself. That is the cleanest approach to deal with hover-driven state-machines in GSAP with out leaking timelines.
The destructive stagger on linkSplits (stagger: -0.03) is value noting too: GSAP helps destructive stagger values that run animations in reverse order throughout the array, which supplies a nicer studying move when phrases seem from a particular facet.
In-App Browser Hell
Price flagging as a result of it price us two days close to the tip of the construct.
When somebody opens a Webflow web site contained in the LinkedIn or Instagram in-app browser, you’re not in Safari or Chrome. You’re in a stripped-down WebView that lags behind the most recent CSS spec by months or years. Properties like aspect-ratio, sure clip-path values, and a few backdrop-filter behaviors silently fail. The animation system principally survived, however layouts broke.
The repair was tedious slightly than intelligent: feature-detect, present CSS fallbacks for each property the in-app browsers didn’t help, and settle for that the location would look 95% as polished in these environments slightly than 100%. The choice was to detect in-app browsers and floor a “faucet to open in browser” immediate, which we thought-about however rejected as a result of it provides friction.
If we did this mission once more, we’d construct the in-app browser fallback layer first, not final.
Reflections
Just a few issues I’d change on a second move.
Construct the cellular structure first, not final. The Lenis-vs-native-scroll choice got here late, and retrofitting it touched virtually each animation on the web page. If we’d designed the scroll system across the cellular constraint from day one, the desktop layer would have been an easy enhancement as a substitute of a default that needed to be torn again out.
Lean even more durable on CSS. Each time we changed a GSAP pin with place: sticky, the web page bought sooner and the code bought smaller. We might have made that swap earlier and extra aggressively. The accordion part is the cleanest instance: virtually all of its conduct lives in two CSS variables and a category toggle.
Webflow’s interactions have caught up since. Once we began, delivering this degree of movement design relied closely on customized code alongside Webflow. At present, you can do roughly 75% of what we shipped right here instantly contained in the Webflow Designer utilizing the brand new visible GSAP timelines, no JS file, no embed, simply keyframes on the canvas. The depth-and-scale feed, the bottom-sticky accordion, and the pivot-locked scaling textual content would nonetheless want actual code, however many of the relaxation wouldn’t. The correct method to consider Webflow now could be: construct every thing you’ll be able to on the visible timelines, then layer customized GSAP solely the place it genuinely can’t attain. Don’t attain for code on reflex.
Efficiency is usually about not animating issues. The most important wins on this mission got here from eradicating animations: changing pins with sticky, changing to with set, caching DOM references, and accepting that scrub 0.3 appears the identical as scrub 0.1. Nothing about including new animation libraries or new optimization tips. Simply doing much less work per body.
Webflow can carry manufacturing websites with this degree of movement design. It simply requires intentionally stepping outdoors the visible builder for the animation layer and treating the customized JS like actual software program slightly than a script snippet you paste on the backside of the web page.
Dwell Website
Go to the dwell web site.


