From our partner: The AI visual builder for Next.js & Tailwind: Generate UI with AI. Customize it with a visual editor.
This project’s been turning heads—and for good reason. Warhol Arts isn’t just visually striking; it’s packed with clever interactions, refined motion, and a whole lot of Webflow and GSAP wizardry under the hood. So we asked Serhii Polyvanyi, founder and creative director at BL/S®, to take us behind the scenes. In this deep dive, he breaks down how the project went from a fun little Dribbble shot to a full-blown digital experience—covering everything from custom animations and performance tricks to the creative process that held it all together.
From a Tiny Dribbble Shot to a Full-Blown Digital Spectacle
This began as a simple Dribbble shot—just a fun, internal experiment. But then our founder took Niccolò Miranda’s course, which mentioned how seamlessly GSAP could be integrated into Webflow—like a Swiss watch. So we put it to the test. Boom—Warhol Arts turned into a full-blown digital wonderland. What started as a side project evolved into something much bigger: an interactive tribute to the king of pop art, Andy Warhol. Bold, unexpected, and full of dynamic GSAP magic.

The Big Idea Behind Warhol Arts
We’re obsessed with Warhol’s fearless creativity. The way he shook up the art world, turned everyday objects into icons, and made us question what art even is. So we thought—how do we channel that energy into a website? The answer: fearless design, explosive colors, and animations that don’t just look cool but tell a story.


Animation Highlights
We knew early on that motion would play a huge role in shaping the personality of Warhol Arts. Animation wasn’t just an afterthought—it was part of the concept from day one. We wanted the site to feel alive, reactive, and unexpected, like a digital extension of Warhol’s energy. In this section, we’ll walk through some of the key interactions that made it happen—from GSAP-driven hero moments and scroll-triggered effects to cursor-based typography and playful micro-interactions. Most of these were built using a combination of GSAP, ScrollTrigger, and Webflow Interactions, layered carefully to stay performant while still feeling bold and expressive.
Hero-section after preloader Letter
The appearance of the letters WARHOL after the preloader is achieved using GSAP, their SplitText plugin and a custom reusable GSAP animation made using registerEffect. It utilizes scale transformations and color changes.
window.Webflow ||= [];
window.Webflow.push(() => {
const COLORS_ARRAY = [
"#FB4E2B",
"#FB4E2B",
"#FB4E2B",
"#FB4E2B",
"#FB4E2B",
"#FB4E2B",
"#FB4E2B",
"#FFE5D5",
];
const STEP_DURATION = 0.1;
const text = document.querySelector('[wb-element="rainbow-text"]');
const delay = parseFloat(text.getAttribute("data-delay")) || 3.4;
gsap.set(text, { autoAlpha: 1 });
const splitText = new SplitText(text, { types: "chars" });
gsap.set(splitText.chars, { color: COLORS_ARRAY[0] });
// create our own custom animation called changeColor
gsap.registerEffect({
name: "changeColor",
effect: (targets, config) => {
return gsap.set(targets, { delay: config.duration, color: config.color });
},
defaults: { duration: STEP_DURATION },
extendTimeline: true, // allows the effect directly on any GSAP timeline
});
gsap.from(splitText.chars, {
scale: 0,
stagger: STEP_DURATION,
delay: delay,
ease: "back.out",
color: (index, target) => {
const tlColors = gsap.timeline();
COLORS_ARRAY.forEach((color) => {
tlColors.changeColor(target, { duration: STEP_DURATION, color: color });
});
},
});
});
Click on the Tube
Clicking on the tube triggers a GSAP animation using the ScrollToPlugin, smoothly scrolling to the next section’s anchor.

Here you can see it in action:
The code looks as follows:
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.7/gsap.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.7/ScrollToPlugin.min.js"></script>
<script>
gsap.registerPlugin(ScrollToPlugin);
document.querySelector('.button-tube-wrapper').addEventListener('click', function(e) {
e.preventDefault();
setTimeout(function() {
gsap.to(window, {
duration: 0.5,
scrollTo: "#4-elvis",
ease: "power2.inOut"
});
}, 1000);
});
</script>
Elvis text moves based on the cursor on the left
As the cursor moves, we capture its X-axis coordinates and dynamically adjust the font size using a GSAP animation, ensuring smooth and optimized transitions. Additionally, we implemented logic to disable the mouse event listener outside this section, keeping the effect contained and efficient.
This is it how it looks:


const section = document.getElementById("4-elvis");
const texts = document.querySelectorAll(".elvis-text");
const minFontSize = 1.375;
const baseFontSize = 2.125;
const maxFontSize = 2.875;
section.addEventListener("mousemove", (e) => {
const rect = section.getBoundingClientRect();
const mouseX = e.clientX - rect.left;
texts.forEach((text) => {
const textRect = text.getBoundingClientRect();
const textCenterX = textRect.left + textRect.width / 2;
const distanceFromCursor = Math.abs(mouseX - textCenterX);
const maxDistance = rect.width / 2;
const normalizedDistance = Math.min(distanceFromCursor / maxDistance, 1);
const fontSize =
maxFontSize - (maxFontSize - minFontSize) * normalizedDistance;
gsap.to(text, {
fontSize: ${fontSize}em,
duration: 0.3,
ease: "power3.out",
overwrite: "auto",
});
});
});
section.addEventListener("mouseleave", () => {
texts.forEach((text) => {
gsap.to(text, {
fontSize: ${baseFontSize}em,
duration: 0.4,
ease: "power3.out",
});
});
});
Text animation on scroll in the Monroe section
Webflow Interactions > While Scrolling in View was used here.




Footer trail effect
The waves were implemented using Webflow animations: Webflow Interactions > While Scrolling in View.




Footer trail effect
The trail effect is also implemented using GSAP, which combines opacity and scale animations with brightness and contrast properties when the cursor moves. GSAP optimizes this animation so smoothly that there are no delays or lags.


showNextImage() {
++this.zIndexVal;
this.imgPosition = this.imgPosition < this.imagesTotal - 1 ? this.imgPosition + 1 : 0;
const img = this.images[this.imgPosition];
gsap.killTweensOf(img.DOM.el);
img.timeline = gsap.timeline({
onStart: this.onImageActivated.bind(this),
onComplete: this.onImageDeactivated.bind(this)
})
.fromTo(img.DOM.el, {
opacity: 1,
scale: 0,
zIndex: this.zIndexVal,
x: cacheMousePos.x - img.rect.width / 2,
y: cacheMousePos.y - img.rect.height / 2
}, {
duration: 0.4,
ease: 'power1',
scale: 1,
x: mousePos.x - img.rect.width / 2,
y: mousePos.y - img.rect.height / 2
}, 0)
.fromTo(img.DOM.inner, {
scale: 2,
filter: 'brightness(300%) contrast(300%)'
}, {
duration: 0.4,
ease: 'power1',
scale: 1,
filter: 'brightness(100%) contrast(100%)'
}, 0)
.to(img.DOM.el, {
duration: 0.4,
ease: 'power3',
opacity: 0
}, 0.4);
}
404 Page – Mouse move over element
Movement of numbers depending on the cursor, implemented using Webflow animations.



Text filled by a mask while scrolling
Text mask filling using the ScrollTrigger plugin provided by GSAP, which allows us to interact with the site through scrolling (a small interactive feature).


function runSplit() {
const typeSplit = new SplitText(".split-word", {
types: "lines, words"
});
document.querySelectorAll(".word").forEach((word) => {
const mask = document.createElement("div");
mask.classList.add("line-mask");
word.appendChild(mask);
});
createAnimation();
}
runSplit();
gsap.registerPlugin(ScrollTrigger);
function createAnimation() {
const allMasks = Array.from(document.querySelectorAll(".line-mask"));
const tl = gsap.timeline({
scrollTrigger: {
trigger: ".split-word",
start: "top 85%",
end: "bottom center",
scrub: 1
}
});
tl.to(allMasks, {
width: "0%",
duration: 1,
stagger: 0.5
});
}
General text appearance across the site using GSAP
Throughout our website, text animations are placed and implemented in a way that they trigger when scrolling reaches a specific element (using the ScrollTrigger plugin). This approach makes it more optimized, as the animation only triggers when needed, rather than running continuously in the background.



window.addEventListener("DOMContentLoaded", () => {
new SplitText("[text-split]", {
types: "words, chars",
tagName: "span"
});
function createScrollTrigger(triggerElement, timeline) {
ScrollTrigger.create({
trigger: triggerElement,
start: "top bottom",
onLeaveBack: () => {
timeline.progress(0).pause();
}
});
ScrollTrigger.create({
trigger: triggerElement,
start: "top 85%",
onEnter: () => timeline.play()
});
}
$("[text-rotate-fade-in]").each(function () {
const delay = parseFloat($(this).attr("data-delay")) || 0;
const tl = gsap.timeline({ paused: true });
tl.from($(this).find(".char"), {
rotation: -45,
opacity: 0,
transformOrigin: "0% 50%",
duration: 0.6,
ease: "back.out(2)",
stagger: 0.03,
delay: delay
});
createScrollTrigger($(this), tl);
});
gsap.set("[text-split]", { opacity: 1 });
});
Optimisation: Delays loading of sections after the preloader and hero
This script waits for the full DOM to load, then monitors the disappearance of the element with the class .preloader
using MutationObserver
. Once the preloader disappears (its display is set to none), it disables the observer and activates the lazy sections (.lazy-section
) by adding the active
class — triggering the deferred content appearance after the loading is complete.
document.addEventListener("DOMContentLoaded", () => {
const preloader = document.querySelector(".preloader");
const lazySections = document.querySelectorAll(".lazy-section");
const activateLazySections = () => {
lazySections.forEach((section) => {
section.classList.add("active");
});
};
const handlePreloaderEnd = () => {
preloader.style.display = "none";
activateLazySections();
};
const preloaderObserver = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.target.style.display === "none") {
handlePreloaderEnd();
preloaderObserver.disconnect();
}
});
});
preloaderObserver.observe(preloader, { attributes: true, attributeFilter: ["style"] });
});

Animation of tab switching in the modal window during the ticket purchase
Thanks to custom and built-in GSAP easing functions, we can create animations like slide/billet transitions. In this case, we chose the following easing: const easing = "power1.out"
.


document.addEventListener("DOMContentLoaded", function () {
const slides = document.querySelectorAll(".step");
const nextButton = document.querySelector(".next");
const prevButton = document.querySelector(".previous");
let currentSlideIndex = 0;
const animationDuration = 0.75;
const easing = "power1.out";
slides.forEach((slide, index) => {
gsap.set(slide, {
y: "0%",
zIndex: index === 0 ? 1 : 0,
position: "absolute",
top: 0,
left: 0,
width: "100%",
height: "100%",
visibility: index === 0 ? "visible" : "hidden",
});
});
function goToSlide(newIndex) {
if (newIndex < 0 newIndex >= slides.length newIndex === currentSlideIndex) return;
const currentSlide = slides[currentSlideIndex];
const nextSlide = slides[newIndex];
gsap.fromTo(
nextSlide,
{ y: "-100%", visibility: "visible", zIndex: 2 },
{ y: "0%", duration: animationDuration, ease: easing }
);
gsap.to(currentSlide, {
zIndex: 0,
onComplete: () => {
gsap.set(currentSlide, { visibility: "hidden" });
},
});
currentSlideIndex = newIndex;
}
nextButton.addEventListener("click", () => goToSlide(currentSlideIndex + 1));
prevButton.addEventListener("click", () => goToSlide(currentSlideIndex - 1));
});
The Inside Scoop: Fun Team Stories & Hidden Gems
During the project, we couldn’t resist sneaking in a little Easter egg—a hidden tribute to our developer that only the keenest eyes will spot. We also made sure no team members were harmed in the making of this project (well, except for the sleepless nights perfecting those animations). The entire process was a creative rollercoaster, filled with unexpected discoveries, last-minute tweaks, and moments of pure excitement as everything finally clicked together. At the end of the day, Warhol Arts became more than just a website—it became a digital art experiment we’re incredibly proud of.
The Final Stroke
Warhol Arts isn’t just a website—it’s an experience. A tribute to a creative rebel. A playground for motion design. And a reminder that pushing boundaries is always worth it.