The project that became Horeca started as a modest landing page but quickly transformed into an intricate web design challenge. The scope expanded to include dynamic text scaling that could adjust to the viewport, sticky headers locking to the bottom, and engaging feed animations with a three-dimensional quality. Initially, accomplishing this level of sophistication necessitated a harmonious blend of Webflow and GSAP, culminating in a hefty 2,000 lines of custom animation code.
What many case studies overlook is the crucial balancing act between design and usability. Sure, it’s easy to showcase a complex animation-heavy website, but if you consider what happens when a client enters a project for maintenance or alterations—things get messy. Stacking absolute positioned divs and hard coding values might sell well in a presentation, but they create chaos in real-world applications. Utilizing Webflow's CMS alongside Lumos for its class system ensured that our design remained responsive and manageable, while GSAP enhanced our animation capabilities beyond what Webflow could deliver alone at that time.
In this study, I plan to outline the most taxing parts of the build process, including the fiddly math behind scaling text, the challenges of crafting a bottom-sticky accordion, navigating mobile-specific complications, and grappling with the often-problematic in-app browser renderings. Today, thanks to recent advancements in Webflow—including visual GSAP timelines—many of these animations can be constructed with far less custom coding.
Technology Stack Overview
- Webflow: Serving as the CMS and visual design platform
- Lumos Framework: Providing the necessary class structure, fluid units, and component architecture
- GSAP: Our animation powerhouse
- ScrollTrigger: Responsible for scroll-dependent animations
- SplitText: For breaking text into manageable portions at the line, word, or character level
- Lenis: Implementing smooth scrolling on desktops only (more details on this limitation later)
- Custom CSS variables to govern theming, spacing, and calculation for the accordion layout
With no build step or bundler to contend with, our project ran as inline custom code directly within Webflow’s settings. This limitation greatly influenced our design decisions.
New to Webflow? Check out Webflow University for accessible courses, tutorials, and guides to help you start your journey.
Key Scroll Strategies That Made a Difference
First Tip: Disable Lenis on Mobile Devices
Lenis provides a slick scrolling experience on desktops, but the consequences on mobile can be debilitating—resulting in glitchy scrolling, erratic sticky elements, and misaligned animations. I spent excessive time trying to troubleshoot these issues, testing configurations and attempting to create elaborate—though ultimately ineffective—workaround solutions. The reality check? The simplest fix was to abandon Lenis entirely on mobile.
window.isMobile = function () {
if (navigator.userAgentData && navigator.userAgentData.mobile !== undefined) {
return navigator.userAgentData.mobile;
}
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
};
if (!isMobile()) {
lenis = new Lenis({ /* ...config */ });
lenis.on("scroll", ScrollTrigger.update);
gsap.ticker.add((time) => lenis.raf(time * 1000));
}
The takeaway? When a library clashes with the operating platform, it may be best to not force it where it doesn’t belong.
Second Tip: Implement a Custom Scrolling Div for iOS
iOS presents a unique headache due to the address bar contracting and expanding as users scroll, causing viewport resizing. This not only disrupts the user experience but can throw off triggering of animations, resulting in unintended re-triggers. The workaround? Manage scrolling within a fixed-height container instead of allowing it to take place on the body element. By applying overflow: hidden to the body, we can designate a .page_wrap div as the scrollable area, effectively stabilizing triggers by preventing address bar interactions.
const MOBILE_SCROLLER = ".page_wrap";
function getScrollContainer() {
return isMobile() ? document.querySelector(MOBILE_SCROLLER) || window : window;
}
ScrollTrigger.defaults({ scroller: getScrollContainer() });
With this adjustment, we improve scroll animation reliability at the expense of added complexity, as managing scrolling now falls on our shoulders.
Dynamic Text Scaling
The design feature called for a text block where specific words would dramatically scale while contextual text recedes vertically, all while anchoring to a chosen character. The basic, naive scaling method involves enlarging the entire text block. While it may look pleasing for a moment, the inevitable document reflow causes a cascading performance issue, pushing the viewport’s dimensions into disarray.
The smarter solution is to apply scaling via transform exclusively. By using CSS sticky for pinning, controlling progression through a single ScrollTrigger, and pre-measuring pivot offsets, we keep the spotlight on the designated character without compromising layout stability.
function 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({
trigger: longScrollSection,
start: "top top",
end: "bottom bottom",
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 critical points emerge from this implementation:
- Single ScrollTrigger with Multiple Derived Progress Values. Instead of supporting multiple triggers with redundant calculations, we manage a single ScrollTrigger that drives all animations.
- Pre-measured pivot offset. Anchoring the pivot at the outset allows us to track it with precision, keeping it in the viewport as we manipulate the surrounding text via scaling transformations.
- Utilize
gsap.setInstead ofgsap.to. In a scroll-based animation context, usinggsap.setto apply values directly enhances performance by circumventing the overhead of constantly generating tweens.
Interestingly, scaling factors differ on mobile (2.95x versus 2.25x), driven by the narrower viewport needing the prominent word to maintain its visual impact.
Sticky Slides with a Card Stack
Our design featured a series of sizable cards that would pin one by one at the top of the viewport, scaling and rotating slightly to create a dynamic entrance for each subsequent card. The underlying instinct was to apply GSAP's pin feature on every individual card, but this approach resulted in unwanted complications—especially when resizes occurred on mobile, leading to significant desynchronization as the address bar altered.
We opted for CSS position: sticky on the wrapper elements, retaining GSAP purely for the scale, rotation, and opacity animations:
const cardsWrappers = gsap.utils.toArray(".slide-wrapper").slice(0, -1);
const cards = gsap.utils.toArray(".card_stack_component");
cardsWrappers.forEach((wrapper, i) => {
const card = cards[i];
gsap.to(card, {
rotationZ: (Math.random() - 0.5) * 10,
scale: 0.7,
rotationX: 40,
ease: "none",
scrollTrigger: {
trigger: wrapper,
start: "top top",
end: "bottom center",
endTrigger: ".g_component_layout",
scrub: !isMobile() ? true : 1,
},
});
gsap.to(card, {
autoAlpha: 0,
ease: "power1.in",
scrollTrigger: {
trigger: card,
start: "top -80%",
end: "+=" + 0.2 * window.innerHeight,
scrub: !isMobile() ? true : 1,
},
});
});
By delegating the pinning responsibility to CSS, performance improved dramatically as GSAP could concentrate on visually enhancing the animation in real-time without bogging down the rendering process.
Accordion Component Sticking to the Bottom
This accordion proved to be one of the most complex design challenges, demanding simple yet innovative solutions. The requirement was for headers that adhered to the bottom of the viewport as one scrolled down, creating a visually appealing stacking effect.
Conventional sticky behavior defaults to anchoring to the top, and there’s no native CSS equivalent for bottom-sticky behavior within a scrolling parent context. Our solution? Position the headers absolutely after initial rendering, manipulating CSS custom properties to determine their landing spots:
const accordionContainer = document.querySelector('[data-gsap="inview"]');
const accordionHeaders = document.querySelectorAll(".accordion_header");
const accordionWrapper = document.querySelector('[data-gsap="accordion-wrapper"]');
let headerHeight = "8rem";
if (accordionContainer && accordionHeaders.length > 0 && accordionWrapper) {
const totalItemsCount = accordionHeaders.length;
const sectionHeight = accordionContainer.getBoundingClientRect().height;
const wrapperHeight = accordionWrapper.offsetHeight;
const headerHeightPx = !isMobile() ? `${wrapperHeight / totalItemsCount}px` : headerHeight;
const sectionHeightPx = `${sectionHeight}px`;
document.documentElement.style.setProperty("--total-items", totalItemsCount);
document.documentElement.style.setProperty("--section-height", sectionHeightPx);
document.documentElement.style.setProperty("--header-height", headerHeightPx);
accordionHeaders.forEach((header, index) => {
const itemPosition = index + 1;
header.style.setProperty("--item-position", itemPosition);
if (!isMobile()) {
setTimeout(() => {
header.style.position = "absolute";
}, 1000);
}
});
}
This JavaScript merely updates a handful of CSS variables, while the actual sticky behavior is dictated via styles based on those variables. On bigger screens, after css calculations are done, the headers are set to position: absolute and CSS manages the stacking.
All of this operates under a singular ScrollTrigger that activates and deactivates an .inview class on scroll:
ScrollTrigger.create({
trigger: accordionWrapper,
start: `top bottom-=${wrapperHeight}`,
onEnter: () => {
accordionContainers.forEach((container) => {
container.classList.add("inview");
});
if (!isMobile()) {
refreshScrollTriggers();
}
},
onLeaveBack: () => {
accordionContainers.forEach((container) => {
container.classList.remove("inview");
});
},
});
The lesson—before defaulting to JavaScript for animation, consider if CSS can achieve a similar effect more efficiently; often, it can.
Feed Section Animation: Cards Emerging From Depth
This animation originally featured in the build but was omitted at the client's behest. Retaining the breakdown provides valuable insights into one of the project's more intricate performance challenges.
We had a footer section where items emerged from a deep z-axis into the foreground as one scrolled, with the closest card becoming the active selection and fading in a related background image. This animation proved particularly performance-sensitive, as it activated on each scroll event, impacting every card concurrently. Early iterations relied on gsap.to within the loop, resulting in a deluge of micro-tweens every second. The optimal fix was to strictly utilize gsap.set and cache elements to improve efficiency.