برچسب: Animations

  • Built to Move: A Closer Look at the Animations Behind Eduard Bodak’s Portfolio

    Built to Move: A Closer Look at the Animations Behind Eduard Bodak’s Portfolio



    For months, Eduard Bodak has been sharing glimpses of his visually rich new website. Now, he’s pulling back the curtain to walk us through how three of its most striking animations were built. In this behind-the-scenes look, he shares the reasoning, technical decisions, and lessons learned—from performance trade-offs to working with CSS variables and a custom JavaScript architecture.

    Overview

    In this breakdown, I’ll walk you through three of the core GSAP animations on my site: flipping 3D cards that animate on scroll, an interactive card that reacts to mouse movement on the pricing page, and a circular layout of cards that subtly rotates as you scroll. I’ll share how I built each one, why I made certain decisions, and what I learned along the way.

    I’m using Locomotive Scroll V5 in this project to handle scroll progress and viewport detection. Since it already offers built-in progress tracking via data attributes and CSS variables, I chose to use that directly for triggering animations. ScrollTrigger offers a lot of similar functionality in a more integrated way, but for this build, I wanted to keep everything centered around Locomotive’s scroll system to avoid overlap between two scroll-handling libraries.

    Personally, I love the simplicity of Locomotive Scroll. You can just add data attributes to specify the trigger offset of the element within the viewport. You can also get a CSS variable --progress on the element through data attributes. This variable represents the current progress of the element and ranges between 0 and 1. This alone can animate a lot with just CSS.

    I used this project to shift my focus toward more animations and visual details. It taught me a lot about GSAP, CSS, and how to adjust animations based on what feels right. I’ve always wanted to build sites that spark a little emotion when people visit them.

    Note that this setup was tailored to the specific needs of the project, but in cases where scroll behavior, animations, and state management need to be tightly integrated, GSAP’s ScrollTrigger and ScrollSmoother can offer a more unified foundation.

    Now, let’s take a closer look at the three animations in action!

    Flipping 3D cards on scroll

    I split the animation into two parts. The first is about the cards escaping on scroll. The second is about them coming back and flipping back.

    Part 01

    We got the three cards inside the hero section.

    <section 
     data-scroll 
     data-scroll-offset="0%, 25%" 
     data-scroll-event-progress="progressHero"
     data-hero-animation>
     <div>
      <div class="card" data-hero-animation-card>
       <div class="card_front">...</div>
       <div class="card_back">...</div>
      </div>
      <div class="card" data-hero-animation-card>
       <div class="card_front">...</div>
       <div class="card_back">...</div>
      </div>
      <div class="card" data-hero-animation-card>
       <div class="card_front">...</div>
       <div class="card_back">...</div>
      </div>
     </div>
    </section>

    While I’m using Locomotive Scroll, I need data-scroll to enable viewport detection on an element. data-scroll-offset specifies the trigger offset of the element within the viewport. It takes two values: one for the offset when the element enters the viewport, and a second for the offset when the element leaves the viewport. The same can be built with GSAP’s ScrollTrigger, just inside the JS.

    data-scroll-event-progress="progressHero" will trigger the custom event I defined here. This event allows you to retrieve the current progress of the element, which ranges between 0 and 1.

    Inside the JS we can add an EventListener based on the custom event we defined. Getting the progress from it and transfer it to the GSAP timeline.

    this.handleProgress = (e) => {
     const { progress } = e.detail;
     this.timeline?.progress(progress);
    };
    
    window.addEventListener("progressHero", this.handleProgress);

    I’m using JS classes in my project, therefore I’m using this in my context.

    Next, we retrieve all the cards.

    this.heroCards = this.element.querySelectorAll("[data-hero-animation-card]");

    this.element is here our section we defined before, so it’s data-hero-animation.

    Building now the timeline method inside the class. Getting the current timeline progress. Killing the old timeline and clearing any GSAP-applied inline styles (like transforms, opacity, etc.) to avoid residue.

    computeDesktopTimeline() {
     const progress = this.timeline?.progress?.() ?? 0;
     this.timeline?.kill?.();
     this.timeline = null;
     gsap.set(this.heroCards, { clearProps: "all" });
    }

    Using requestAnimationFrame() to avoid layout thrashing. Initializes a new, paused GSAP timeline. While we are using Locomotive Scroll it’s important that we pause the timeline, so the progress of Locomotive can handle the animation.

    computeDesktopTimeline() {
     const progress = this.timeline?.progress?.() ?? 0;
     this.timeline?.kill?.();
     this.timeline = null;
     gsap.set(this.heroCards, { clearProps: "all" });
    
     requestAnimationFrame(() => {
      this.timeline = gsap.timeline({ paused: true });
    
      this.timeline.progress(progress);
      this.timeline.paused(true);
     });
    }

    Figuring out relative positioning per card. targetY moves each card down so it ends near the bottom of the container. yOffsets and rotationZValues give each card a unique vertical offset and rotation.

    computeDesktopTimeline() {
     const progress = this.timeline?.progress?.() ?? 0;
     this.timeline?.kill?.();
     this.timeline = null;
     gsap.set(this.heroCards, { clearProps: "all" });
    
     requestAnimationFrame(() => {
      this.timeline = gsap.timeline({ paused: true });
    
      this.heroCards.forEach((card, index) => {
       const position = index - 1;
       const elementRect = this.element.getBoundingClientRect();
       const cardRect = this.heroCards[0]?.getBoundingClientRect();
       const targetY = elementRect.height - cardRect.height;
       const yOffsets = [16, 32, 48];
       const rotationZValues = [-12, 0, 12];
      
       // timeline goes here
      });
    
      this.timeline.progress(progress);
      this.timeline.paused(true);
     });
    }

    The actual GSAP timeline. Cards slide left or right based on their index (x). Rotate on Z slightly to look scattered. Slide downward (y) to target position. Shrink and tilt (scale, rotateX) for a 3D feel. index * 0.012: adds a subtle stagger between cards.

    computeDesktopTimeline() {
     const progress = this.timeline?.progress?.() ?? 0;
     this.timeline?.kill?.();
     this.timeline = null;
     gsap.set(this.heroCards, { clearProps: "all" });
    
     requestAnimationFrame(() => {
      this.timeline = gsap.timeline({ paused: true });
    
      this.heroCards.forEach((card, index) => {
       const position = index - 1;
       const elementRect = this.element.getBoundingClientRect();
       const cardRect = this.heroCards[0]?.getBoundingClientRect();
       const targetY = elementRect.height - cardRect.height;
       const yOffsets = [16, 32, 48];
       const rotationZValues = [-12, 0, 12];
    
       this.timeline.to(
        card,
         {
          force3D: true,
          keyframes: {
           "75%": {
            x: () => -position * (card.offsetWidth * 0.9),
            rotationZ: rotationZValues[index],
           },
           "100%": {
            y: () => targetY - yOffsets[index],
            scale: 0.85,
            rotateX: -16,
           },
          },
         },
        index * 0.012
       );
      });
    
      this.timeline.progress(progress);
      this.timeline.paused(true);
     });
    }

    That’s our timeline for desktop. We can now set up GSAP’s matchMedia() to use it. We can also create different timelines based on the viewport. For example, to adjust the animation on mobile, where such an immersive effect wouldn’t work as well. Even for users who prefer reduced motion, the animation could simply move the cards slightly down and fade them out, as you can see on the live site.

    setupBreakpoints() {
     this.mm.add(
      {
       desktop: "(min-width: 768px)",
       mobile: "(max-width: 767px)",
       reducedMotion: "(prefers-reduced-motion: reduce)",
      },
      (context) => {
       this.timeline?.kill?.();
    
       if (context.conditions.desktop) this.computeDesktopTimeline();
    
       return () => {
        this.timeline?.kill?.();
       };
      }
     );
    }

    Add this to our init() method to initialize the class when we call it.

    init() {
     this.setupBreakpoints();
    }

    We can also add a div with a background color on top of the card and animate its opacity on scroll so it smoothly disappears.

    When you look closely, the cards are floating a bit. To achieve that, we can add a repeating animation to the cards. It’s important to animate yPercent here, because we already animated y earlier, so there won’t be any conflicts.

    gsap.fromTo(
     element,
     {
      yPercent: -3,
     },
     {
      yPercent: 3,
      duration: () => gsap.utils.random(1.5, 2.5),
      ease: "sine.inOut",
      repeat: -1,
      repeatRefresh: true,
      yoyo: true,
     }
    );

    gsap.utils.random(1.5, 2.5) comes in handy to make each floating animation a bit different, so it looks more natural. repeatRefresh: true lets the duration refresh on every repeat.

    Part 02

    We basically have the same structure as before. Only now we’re using a sticky container. The service_container has height: 350vh, and the service_sticky has min-height: 100vh. That’s our space to play the animation.

    <section 
     data-scroll 
     data-scroll-offset="5%, 75%" 
     data-scroll-event-progress="progressService"
     data-service-animation>
     <div class="service_container">
      <div class="service_sticky">
       <div class="card" data-service-animation-card>
        <div class="card_front">...</div>
        <div class="card_back">...</div>
       </div>
       <div class="card" data-service-animation-card>
        <div class="card_front">...</div>
        <div class="card_back">...</div>
       </div>
       <div class="card" data-service-animation-card>
        <div class="card_front">...</div>
        <div class="card_back">...</div>
       </div>
      </div>
     </div>
    </section>

    In the JS, we can use the progressService event as before to get our Locomotive Scroll progress. We just have another timeline here. I’m using keyframes to really fine-tune the animation.

    this.serviceCards.forEach((card, index) => {
      const position = 2 - index - 1;
      const rotationZValues = [12, 0, -12];
      const rotationZValuesAnimated = [5, 0, -5];
    
      this.timeline.to(
        card,
        {
          force3D: true,
          keyframes: {
            "0%": {
              y: () => -0.75 * window.innerHeight + 1,
              x: () => -position * (card.offsetWidth * 1.15),
              scale: 0.2,
              rotationZ: rotationZValues[index],
              rotateX: 24,
            },
            "40%": {
              y: "20%",
              scale: 0.8,
              rotationZ: rotationZValuesAnimated[index],
              rotationY: 0,
              rotateX: 0,
            },
            "55%": { rotationY: 0, y: 0, x: () => gsap.getProperty(card, "x") },
            "75%": { x: 0, rotationZ: 0, rotationY: -190, scale: 1 },
            "82%": { rotationY: -180 },
            "100%": { rotationZ: 0 },
          },
        },
        index * 0.012
      );
    });

    const position = 2 - index - 1 changes the position, so cards start spread out: right, center, left. With that we can use those arrays [12, 0, -12] in the right order.

    There’s the same setupBreakpoints() method as before, so we actually just need to change the timeline animation and can use the same setup as before, only in a new JS class.

    We can add the same floating animation we used in part 01, and then we have the disappearing/appearing card effect.

    Part 2.1

    Another micro detail in that animation is the small progress preview of the three cards in the top right.

    We add data-scroll-css-progress to the previous section to get a CSS variable --progress ranging from 0 to 1, which can be used for dynamic CSS effects. This data attribute comes from Locomotive Scroll.

    <section 
     data-scroll 
     data-scroll-offset="5%, 75%" 
     data-scroll-event-progress="progressService"
     data-scroll-css-progress
     data-service-animation>
     ...
     <div>
      <div class="tiny-card">...</div>
      <div class="tiny-card">...</div>
      <div class="tiny-card">...</div>
     </div>
     ...
    </section>

    Using CSS calc() with min() and max() to trigger animations at specific progress points. In this case, the first animation starts at 0% and finishes at 33%, the second starts at 33% and finishes at 66%, and the last starts at 66% and finishes at 100%.

    .tiny-card {
     &:nth-child(1) {
      mask-image: linear-gradient(to top, black calc(min(var(--progress), 0.33) * 300%), rgba(0, 0, 0, 0.35) calc(min(var(--progress), 0.33) * 300%));
      transform: translate3d(0, calc(rem(4px) * (1 - min(var(--progress) * 3, 1))), 0);
     }
    
     &:nth-child(2) {
      mask-image: linear-gradient(
       to top,
       black calc(max(min(var(--progress) - 0.33, 0.33), 0) * 300%),
       rgba(0, 0, 0, 0.35) calc(max(min(var(--progress) - 0.33, 0.33), 0) * 300%)
      );
      transform: translate3d(0, calc(rem(4px) * (1 - min(max((var(--progress) - 0.33) * 3, 0), 1))), 0);
     }
    
     &:nth-child(3) {
      mask-image: linear-gradient(
       to top,
       black calc(max(min(var(--progress) - 0.66, 0.34), 0) * 300%),
       rgba(0, 0, 0, 0.35) calc(max(min(var(--progress) - 0.66, 0.34), 0) * 300%)
      );
      transform: translate3d(0, calc(rem(4px) * (1 - min(max((var(--progress) - 0.66) * 3, 0), 1))), 0);
     }
    }

    Card rotating on mouse movement

    The card is built like the previous ones. It has a front and a back.

    <div class="card" data-price-card>
     <div class="card_front">...</div>
     <div class="card_back">...</div>
    </div>

    On a closer look, you can see a small slide-in animation of the card before the mouse movement takes effect. This is built in GSAP using the onComplete() callback in the timeline. this.card refers to the element with data-price-card.

    this.introTimeline = gsap.timeline();
    
    this.introTimeline.fromTo(
     this.card,
     {
      rotationZ: 0,
      rotationY: -90,
      y: "-4em",
     },
     {
      rotationZ: 6,
      rotationY: 0,
      y: "0em",
      duration: 1,
      ease: "elastic.out(1,0.75)",
      onComplete: () => {
       this.initAnimation();
      },
     }
    );

    I’m using an elastic easing that I got from GSAPs Ease Visualizer. The timeline plays when the page loads and triggers the mouse movement animation once complete.

    In our initAnimation() method, we can use GSAP’s matchMedia() to enable the mouse movement only when hover and mouse input are available.

    this.mm = gsap.matchMedia();
    
    initAnimation() {
     this.mm.add("(hover: hover) and (pointer: fine) and (prefers-reduced-motion: no-preference)", () => {
      gsap.ticker.add(this.mouseMovement);
    
      return () => {
       gsap.ticker.remove(this.mouseMovement);
      };
     });
    
     this.mm.add("(hover: none) and (pointer: coarse) and (prefers-reduced-motion: no-preference)", () => {
      ...
     });
    }

    By using the media queries hover: hover and pointer: fine, we target only devices that support a mouse and hover. With prefers-reduced-motion: no-preference, we add this animation only when reduced motion is not enabled, making it more accessible. For touch devices or smartphones, we can use hover: none and pointer: coarse to apply a different animation.

    I’m using gsap.ticker to run the method this.mouseMovement, which contains the logic for handling the rotation animation.

    I originally started with one of the free resources from Osmo (mouse follower) and built this mouse movement animation on top of it. I simplified it to only use the mouse’s x position, which was all I needed.

    constructor() {
      this.rotationFactor = 200;
      this.zRotationFactor = 15;
      this.centerX = window.innerWidth / 2;
      this.centerY = window.innerHeight / 2;
    
      this.currentMouseX = 0;
    
      window.addEventListener("mousemove", e => {
        this.currentMouseX = e.clientX;
      });
    }
    
    mouseMovement() {
      const mouseX = this.currentMouseX;
      const normalizedX = (mouseX - this.centerX) / this.centerX;
      const rotationY = normalizedX * this.rotationFactor;
      const absRotation = Math.abs(rotationY);
      const rotationProgress = Math.min(absRotation / 180, 1);
      const rotationZ = 6 - rotationProgress * 12;
      const rotationZMirror = -6 + rotationProgress * 12;
    
      gsap.to(this.card, {
        rotationY: rotationY,
        rotationZ: rotationZ,
        duration: 0.5,
        ease: "power2.out",
      });
    }

    I also added calculations for how much the card can rotate on the y-axis, and it rotates the z-axis accordingly. That’s how we get this mouse movement animation.

    When building these animations, there are always some edge cases I didn’t consider before. For example, what happens when I move my mouse outside the window? Or if I hover over a link or button, should the rotation animation still play?

    I added behavior so that when the mouse moves outside, the card rotates back to its original position. The same behavior applies when the mouse leaves the hero section or hovers over navigation elements.

    I added a state flag this.isHovering. At the start of mouseMovement(), we check if this.isHovering is false, and if so, return early. The onMouseLeave method rotates the card back to its original position.

    mouseMovement() {
      if (!this.card || !this.isHovering) return;
    
      ...
    }
    
    onMouseEnter() {
      this.isHovering = true;
    }
    
    onMouseLeave() {
      this.isHovering = false;
    
      gsap.to(this.card, {
        rotationX: 0,
        rotationY: 0,
        rotationZ: 6,
        duration: 1.5,
        ease: "elastic.out(1,0.75)",
      });
    }

    Using our initAnimation() method from before, with these adjustments added.

    initAnimation() {
     this.mm.add("(hover: hover) and (pointer: fine) and (prefers-reduced-motion: no-preference)", () => {
      this.container.addEventListener("mouseenter", this.onMouseEnter);
      this.container.addEventListener("mouseleave", this.onMouseLeave);
      gsap.ticker.add(this.mouseMovement);
    
      return () => {
       this.container.removeEventListener("mouseenter", this.onMouseEnter);
       this.container.removeEventListener("mouseleave", this.onMouseLeave);
       gsap.ticker.remove(this.mouseMovement);
      };
     });
    
     this.mm.add("(hover: none) and (pointer: coarse) and (prefers-reduced-motion: no-preference)", () => {
      ...
     });
    }

    And here we have the mouse enter/leave behavior.

    We can adjust it further by adding another animation for mobile, since there’s no mouse movement there. Or a subtle reflection effect on the card like in the video. This is done by duplicating the card, adding an overlay with a gradient and backdrop-filter, and animating it similarly to the original card, but with opposite values.

    Cards in a circular position that slightly rotate on scroll

    First, we build the base of the circularly positioned cards in CSS.

    <div class="wheel" style="--wheel-angle: 15deg">
     <div class="wheel_items">
      <div class="wheel_item-wrap" style="--wheel-index: 0"><div class="wheel_item">...</div></div>
      <div class="wheel_item-wrap" style="--wheel-index: 1"><div class="wheel_item">...</div></div>
      <div class="wheel_item-wrap" style="--wheel-index: 2"><div class="wheel_item">...</div></div>
      <div class="wheel_item-wrap" style="--wheel-index: 3"><div class="wheel_item">...</div></div>
      <div class="wheel_item-wrap" style="--wheel-index: 4"><div class="wheel_item">...</div></div>
      <div class="wheel_item-wrap" style="--wheel-index: 5"><div class="wheel_item">...</div></div>
      <div class="wheel_item-wrap" style="--wheel-index: 6"><div class="wheel_item">...</div></div>
      <div class="wheel_item-wrap" style="--wheel-index: 7"><div class="wheel_item">...</div></div>
      <div class="wheel_item-wrap" style="--wheel-index: 8"><div class="wheel_item">...</div></div>
      <div class="wheel_item-wrap" style="--wheel-index: 9"><div class="wheel_item">...</div></div>
      <div class="wheel_item-wrap" style="--wheel-index: 10"><div class="wheel_item">...</div></div>
      <div class="wheel_item-wrap" style="--wheel-index: 11"><div class="wheel_item">...</div></div>
      <div class="wheel_item-wrap" style="--wheel-index: 12"><div class="wheel_item">...</div></div>
      <div class="wheel_item-wrap" style="--wheel-index: 13"><div class="wheel_item">...</div></div>
      <div class="wheel_item-wrap" style="--wheel-index: 14"><div class="wheel_item">...</div></div>
      <div class="wheel_item-wrap" style="--wheel-index: 15"><div class="wheel_item">...</div></div>
      <div class="wheel_item-wrap" style="--wheel-index: 16"><div class="wheel_item">...</div></div>
      <div class="wheel_item-wrap" style="--wheel-index: 17"><div class="wheel_item">...</div></div>
      <div class="wheel_item-wrap" style="--wheel-index: 18"><div class="wheel_item">...</div></div>
      <div class="wheel_item-wrap" style="--wheel-index: 19"><div class="wheel_item">...</div></div>
      <div class="wheel_item-wrap" style="--wheel-index: 20"><div class="wheel_item">...</div></div>
      <div class="wheel_item-wrap" style="--wheel-index: 21"><div class="wheel_item">...</div></div>
      <div class="wheel_item-wrap" style="--wheel-index: 22"><div class="wheel_item">...</div></div>
      <div class="wheel_item-wrap" style="--wheel-index: 23"><div class="wheel_item">...</div></div>
     </div>
    </div>

    At first, we add all 24 cards, then remove the ones we don’t want to show later because we don’t see them. In the CSS, the .wheel uses a grid display, so we apply grid-area: 1 / 1 to stack the cards. We later add an overlay before the wheel with the same grid-area. By using em we can use a fluid font-size to adjust the size pretty smooth on resizing the viewport.

    .wheel {
     aspect-ratio: 1;
     pointer-events: none;
     grid-area: 1 / 1;
     place-self: flex-start center;
     width: 70em;
    }

    We use the same grid stacking technique for the items. On the item wrapper, we apply the CSS variables defined in the HTML to rotate the cards.

    .wheel_items {
     width: 100%;
     height: 100%;
     display: grid;
    }
    
    .wheel_item-wrap {
     transform: rotate(calc(var(--wheel-angle) * var(--wheel-index)));
     grid-area: 1 / 1;
     justify-self: center;
     height: 100%;
    }

    Inside the item, there is only an image of the card background. The item uses translateY(-100%) to position the card at the top edge of the item.

    .wheel_item {
     transform: translateY(-100%);
     aspect-ratio: 60 / 83;
     width: 7.5em;
    }

    We can remove the card from 8 to 19 as we don’t see them behind the overlay. It should look like this now.

    By adding the data attributes and setup for viewport detection from Locomotive Scroll, which we used in previous modules, we can simply add our GSAP timeline for the rotation animation.

    this.timeline = gsap.timeline({ paused: true });
    
    this.timeline.to(this.wheel, {
     rotate: -65,
     duration: 1,
     ease: "linear",
    });

    We can add a gradient overlay on top of the cards.

    .wheel_overlay {
     background-image: linear-gradient(#fff0, #0000003d 9%, #00000080 16%, #000000b8 22%, #000 32%);
     width: 100%;
     height: 100%;
    }

    And that’s our final effect.

    Conclusion

    There are probably smarter ways to build these animations than I used. But since this is my first site after changing my direction and GSAP, Locomotive Scroll V5, Swup.js, and CSS animations, I’m pretty happy with the result. This project became a personal playground for learning, it really shows that you learn best by building what you imagine. I don’t know how many times I refactored my code along the way, but it gave me a good understanding of creating accessible animations.

    I also did a lot of other animations on the site, mostly using CSS animations combined with JavaScript for the logic behind them.

    There are also so many great resources out there to learn GSAP and CSS.

    Where I learned the most:

    It’s all about how you use it. You can copy and paste, which is fast but doesn’t help you learn much. Or you can build on it your own way and make it yours, that’s at least what helped me learn the most in the end.



    Source link

  • Making Animations Smarter with Data Binding: Creating a Dynamic Gold Calculator in Rive

    Making Animations Smarter with Data Binding: Creating a Dynamic Gold Calculator in Rive


    Designing visuals that respond to real-time data or user input usually means switching between multiple tools — one for animation, another for logic, and yet another for implementation. This back-and-forth can slow down iteration, make small changes cumbersome, and create a disconnect between design and behavior.

    If you’ve spent any time with Rive, you know it’s built to close that gap. It lets you design, animate, and add interaction all in one place — and with features like state machines and data binding, you can make your animations respond directly to variables and user actions.

    To demonstrate how we use data binding in Rive, we built a small interactive project — a gold calculator. The task was simple: calculate the price of 5g and 10g gold bars, from 1 to 6 bars, using external data for the current gold price per gram. The gold price can be dynamic, typically coming from market data, but in this case we used a manually set value.

    Let’s break down how the calculator is built, step by step, starting with the layout and structure of the file.

    1. File Structure

    The layout is built for mobile, using a 440×900 px artboard. It’s structured around three layout groups:

    1. Title with gold price per gram
    2. Controls for choosing gold bar amount and weight
    3. Gold bar illustration

    The title section includes a text layout made of two text runs: one holds static text like the label, while the other is dynamic and connected to external data using data binding. This allows the gold price to update in real time when the data changes.

    In the controls section, we added plus and minus buttons to set the number of gold bars. These are simple layouts with icons inside. Below them, there are two buttons to switch between 5g and 10g options. They’re styled as rounded layouts with text inside.

    In the state machine, two timelines define the tab states: one for when the 10g button is active, using a solid black background and white text, and another for 5g, with reversed styles. Switching between these two updates the active tab visually.

    The total price section also uses two text runs — one for the currency icon and one for the total value. This value changes based on the selected weight and quantity, and is driven by data binding.

    2. Gold Bar Illustration

    The illustration is built using a nested artboard with a single vector gold bar. Inside the calculator layout, we duplicated this artboard to show anywhere from 1 to 6 bars depending on the user’s selection.

    Since there are two weight options, we made the gold bar resize visually — wider for 10g and narrower for 5g. To do that, we used N-Slices so that the edges stay intact and only the middle stretches. The sliced group sits inside a fixed-size layout, and the artboard is set to Hug its contents, which lets it resize automatically.

    Created two timelines to control bar size: one where the width is 88px for 10g, and another at 74px for 5g. The switch between them is controlled by a number variable called Size-gram gold, where 5g is represented by 0 and 10g by 1 with 1 set as the default value.

    In the state machine, we connected this variable to the two timelines (the 10g timeline set as the default)— when it’s set to 0, the layout switches to 5g; when it’s 1, it switches to 10g. This makes the size update based on user selection without any manual switching. To keep the transition smooth, a 150ms animation duration is added.

    3. Visualizing 1–6 Gold Bars

    To show different quantities of gold bars in the main calculator layout, we created a tiered structure using three stacked layout groups with a vertical gap -137. Each tier is offset vertically to form a simple pyramid arrangement, with everything positioned in the bottom-left corner of the screen.

    The first tier contains three duplicated nested artboards of a single gold bar. Each of these is wrapped in a Hug layout, which allows them to resize correctly based on the weight. The second tier includes two gold bars and an empty layout. This empty layout is used for spacing — it creates a visual shift when we need to display exactly four bars. The top tier has just one gold bar centered.

    All three tiers are bottom-centered, which keeps the pyramid shape consistent as bars are added or removed.

    To control how many bars are visible, we created 6 timelines in Animate mode — one for each quantity from 1 to 6. To hide or show each gold bar, two techniques are used: adjusting the opacity of the nested artboard (100% to show, 0% to hide) and modifying the layout that wraps it. When a bar is hidden, the layout is set to a fixed width of 0px; when visible, it uses Hug settings to restore its size automatically.

    Each timeline has its own combination of these settings depending on which bars should appear. For example, in the timeline with 4 bars, we needed to prevent the fourth bar from jumping to the center of the row. To keep it properly spaced, we assigned a fixed width of 80px to the empty layout used for shifting. On the other timelines, that same layout is hidden by setting its width to 0px.

    This system makes it easy to switch between quantities while preserving the visual structure.

    4. State Machine and Data Binding Setup

    With the visuals and layouts ready, we moved on to setting up the logic with data binding and state transitions.

    4.1 External Gold Price

    First, we created a number variable called Gold price gram. This value can be updated externally — for example, connected to a trading database — so the calculator always shows the current market price of gold. In our case, we used a static value of 151.75, which can also be updated manually by the user.

    To display this in the UI, we bound Text Run 2 in the title layout to this variable. A converter in the Strings tab called “Convert to String Price” is then created and applied to that text run. This converter formats the number correctly for display and will be reused later.

    4.2 Gold Bar Size Control

    We already had a number variable called Size-gram gold, which controls the weight of the gold bar used in the nested artboard illustration.

    In the Listeners panel, two listeners are created. The first is set to target the 5g tab, uses a Pointer Down action, and assigns Size-gram gold = 0. The second targets the 10g tab, also with a Pointer Down action, and assigns Size-gram gold = 1.

    Next, two timelines (one for each tab state) are brought into the state machine. The 10g timeline is used as the default state, with transitions added: one from 10g to 5g when Size-gram gold = 0, and one back to 10g when Size-gram gold = 1. Each transition has a duration of 100ms to keep the switching smooth.

    4.3 Gold Bar Quantity

    Next, added another number variable, Quantity-gold, to track the number of selected bars. The default value is set to 1. In the Converters under Numeric, two “Calculate” converters are created — one that adds “+1” and one that subtracts “-1”.

    In the Listeners panel, the plus button is assigned an action: Quantity-gold = Quantity-gold, using the “+1” converter. This way, clicking the plus button increases the count by 1. The same is done for the minus button, assigning Quantity-gold = Quantity-gold and attaching the “-1” converter. Clicking the minus button decreases the count by 1.

    Inside the state machine, six timelines are connected to represent bar counts from 1 to 6. Each transition uses the Quantity-gold value to trigger the correct timeline.

    By default, the plus button would keep increasing the value endlessly, but the goal is to limit the max to six bars. On the timeline where six gold bars are active, the plus button is disabled by setting its click area scale to 0 and lowering its opacity to create a “disabled” visual state. On all other timelines, those properties are returned to their active values.

    The same logic is applied to the minus button to prevent values lower than one. On the timeline with one bar, the button is disabled, and on all others, it returns to its active state.

    Almost there!

    4.4 Total Price Logic

    For the 5g bar price, we calculated it using this formula:

    Total Price = Gold price gram + Quantity-gold * 5

    In Converters → Numeric, a Formula converter was created and named Total Price 5g Formula to calculate the total price. In the example, it looked like:

    {{View Model Price/Gold price gram}}*{{View Model Price/Quanity-gold}}*5.0

    Since we needed to display this number as text, the Total Price number variable was also converted into a string. For that, we used an existing converter called “Convert to String Price.”

    To use both converters together, a Group of converters was created and named Total Price 5g Group, which included the Total Price 5g Formula converter followed by the Convert to String Price converter.

    Then, the text for the price variable was data bound by adding the Total Price variable in the Property field and selecting Total Price 5g Group in the Convert field.

    To handle the 10g case, which is double the price, two options are explored — either creating a new converter that multiplies by 10 or multiplying the existing result by 2.

    Eventually, a second text element is added along with a new group of converters specifically for 10g. This includes a new formula:

    Total Price = Gold price gram + Quantity-gold * 10

    A formula converter and a group with both that formula and the string converter are created and named “Total Price 10g Group.”

    Using timelines where the 5g and 10g buttons are in their active states, we adjusted the transparency of the text elements. This way, the total price connected to the 5g converters group is visible when the 5g button is selected, and the price from the 10g converters group appears when the 10g button is selected.

    It works perfectly.

    After this setup, the Gold price gram variable can be connected to live external data, allowing the gold price in the calculator to reflect the current market value in real time.

    Wrapping Up

    This gold calculator project is a simple example, but it shows how data binding in Rive can be used to connect visual design with real-time logic — without needing to jump between separate tools or write custom code. By combining state machines, variables, and converters, you can build interfaces that are not only animated but also smart and responsive.

    Whether you’re working on a product UI, a prototype, or a standalone interactive graphic, Rive gives you a way to bring together motion and behavior in a single space. If you’re already experimenting with Rive, data binding opens up a whole new layer of possibilities to explore.



    Source link

  • How To Create Kinetic Image Animations with React-Three-Fiber

    How To Create Kinetic Image Animations with React-Three-Fiber



    For the past few months, I’ve been exploring different kinetic motion designs with text and images. The style looks very intriguing, so I decided to create some really cool organic animations using images and React Three Fiber.

    In this article, we’ll learn how to create the following animation using Canvas2D and React Three Fiber.

    Setting Up the View & Camera

    The camera’s field of view (FOV) plays a huge role in this project. Let’s keep it very low so it looks like an orthographic camera. You can experiment with different perspectives later. I prefer using a perspective camera over an orthographic one because we can always try different FOVs. For more detailed implementation check source code.

    <PerspectiveCamera makeDefault fov={7} position={[0, 0, 70]} near={0.01} far={100000} />

    Setting Up Our 3D Shapes

    First, let’s create and position 3D objects that will display our images. For this example, we need to make 2 components:

    Billboard.tsx – This is a cylinder that will show our stack of images

    'use client';
    
    import { useRef } from 'react';
    import * as THREE from 'three';
    
    function Billboard({ radius = 5, ...props }) {
        const ref = useRef(null);
    
        return (
            <mesh ref={ref} {...props}>
                <cylinderGeometry args={[radius, radius, 2, 100, 1, true]} />
                <meshBasicMaterial color="red" side={THREE.DoubleSide} />
            </mesh>
        );
    }

    Banner.tsx – This is another cylinder that will work like a moving banner

    'use client';
    
    import * as THREE from 'three';
    import { useRef } from 'react';
    
    function Banner({ radius = 1.6, ...props }) {
        const ref = useRef(null);
    
        return (
            <mesh ref={ref} {...props}>
                <cylinderGeometry
                args={[radius, radius, radius * 0.07, radius * 80, radius * 10, true]}
                />
                <meshBasicMaterial
                color="blue"
                side={THREE.DoubleSide}
                />
            </mesh>
        );
    }
    
    export default Banner;

    Once we have our components ready, we can use them on our page.

    Now let’s build the whole shape:

    1. Create a wrapper group – We’ll make a group that wraps all our components. This will help us rotate everything together later.

    page.jsx

    'use client';
    
    import styles from './page.module.scss';
    import Billboard from '@/components/webgl/Billboard/Billboard';
    import Banner from '@/components/webgl/Banner/Banner';
    import { View } from '@/webgl/View';
    import { PerspectiveCamera } from '@react-three/drei';
    
    export default function Home() {
        return (
            <div className={styles.page}>
                <View className={styles.view} orbit={false}>
                <PerspectiveCamera makeDefault fov={7} position={[0, 0, 70]} near={0.01} far={100000} /> 
                    <group>
    
                    </group>
                </View>
            </div>
        );
    }

    2. Render Billboard and Banner components in the loop – Inside our group, we’ll create a loop to render our Billboards and Banners multiple times.

    page.jsx

    'use client';
    
    import styles from './page.module.scss';
    import Billboard from '@/components/webgl/Billboard/Billboard';
    import Banner from '@/components/webgl/Banner/Banner';
    import { View } from '@/webgl/View';
    import { PerspectiveCamera } from '@react-three/drei';
    
    export default function Home() {
        return (
            <div className={styles.page}>
                <View className={styles.view} orbit={false}>
                <PerspectiveCamera makeDefault fov={7} position={[0, 0, 70]} near={0.01} far={100000} />
                    <group>
                        {Array.from({ length: COUNT }).map((_, index) => [
                            <Billboard
                            key={`billboard-${index}`}
                            radius={5}
                            />,
                            <Banner
                            key={`banner-${index}`}
                            radius={5}
                            />,
                        ])}
                    </group>
                </View>
            </div>
        );
    }

    3. Stack them up – We’ll use the index from our loop and the y position to stack our items on top of each other. Here’s how it looks so far:

    page.jsx

    'use client';
    
    import styles from './page.module.scss';
    import Billboard from '@/components/webgl/Billboard/Billboard';
    import Banner from '@/components/webgl/Banner/Banner';
    import { View } from '@/webgl/View';
    import { PerspectiveCamera } from '@react-three/drei';
    
    const COUNT = 10;
    const GAP = 3.2;
    
    export default function Home() {
        return (
            <div className={styles.page}>
                <View className={styles.view} orbit={false}>
                <PerspectiveCamera makeDefault fov={7} position={[0, 0, 70]} near={0.01} far={100000} />
                    <group>
                        {Array.from({ length: COUNT }).map((_, index) => [
                            <Billboard
                            key={`billboard-${index}`}
                            radius={5}
                            position={[0, (index - (Math.ceil(COUNT / 2) - 1)) * GAP, 0]}
                            />,
                            <Banner
                            key={`banner-${index}`}
                            radius={5}
                            position={[0, (index - (Math.ceil(COUNT / 2) - 1)) * GAP - GAP * 0.5, 0]}
                            />,
                        ])}
                    </group>
                </View>
            </div>
        );
    }

    4. Add some rotation – Let’s rotate things a bit! First, I’ll hard-code the rotation of our banners to make them more curved and fit nicely with the Billboard component. We’ll also make the radius a bit bigger.

    page.jsx

    'use client';
    
    import styles from './page.module.scss';
    import Billboard from '@/components/webgl/Billboard/Billboard';
    import Banner from '@/components/webgl/Banner/Banner';
    import { View } from '@/webgl/View';
    import { PerspectiveCamera } from '@react-three/drei';
    
    const COUNT = 10;
    const GAP = 3.2;
    
    export default function Home() {
        return (
            <div className={styles.page}>
                <View className={styles.view} orbit={false}>
                <PerspectiveCamera makeDefault fov={7} position={[0, 0, 70]} near={0.01} far={100000} />
                    <group>
                        {Array.from({ length: COUNT }).map((_, index) => [
                            <Billboard
                            key={`billboard-${index}`}
                            radius={5}
                            position={[0, (index - (Math.ceil(COUNT / 2) - 1)) * GAP, 0]}
                            rotation={[0, index * Math.PI * 0.5, 0]} // <-- rotation of the billboard
                            />,
                            <Banner
                            key={`banner-${index}`}
                            radius={5}
                            rotation={[0, 0, 0.085]} // <-- rotation of the banner
                            position={[0, (index - (Math.ceil(COUNT / 2) - 1)) * GAP - GAP * 0.5, 0]}
                            />,
                        ])}
                    </group>
                </View>
            </div>
        );
    }

    5. Tilt the whole thing – Now let’s rotate our entire group to make it look like the Leaning Tower of Pisa.

    page.jsx

    'use client';
    
    import styles from './page.module.scss';
    import Billboard from '@/components/webgl/Billboard/Billboard';
    import Banner from '@/components/webgl/Banner/Banner';
    import { View } from '@/webgl/View';
    import { PerspectiveCamera } from '@react-three/drei';
    
    const COUNT = 10;
    const GAP = 3.2;
    
    export default function Home() {
        return (
            <div className={styles.page}>
                <View className={styles.view} orbit={false}>
                <PerspectiveCamera makeDefault fov={7} position={[0, 0, 70]} near={0.01} far={100000} />
                    <group rotation={[-0.15, 0, -0.2]}> // <-- rotate the group
                        {Array.from({ length: COUNT }).map((_, index) => [
                            <Billboard
                            key={`billboard-${index}`}
                            radius={5}
                            position={[0, (index - (Math.ceil(COUNT / 2) - 1)) * GAP, 0]}
                            rotation={[0, index * Math.PI * 0.5, 0]}
                            />,
                            <Banner
                            key={`banner-${index}`}
                            radius={5}
                            rotation={[0, 0, 0.085]}
                            position={[0, (index - (Math.ceil(COUNT / 2) - 1)) * GAP - GAP * 0.5, 0]}
                            />,
                        ])}
                    </group>
                </View>
            </div>
        );
    }

    6. Perfect! – Our 3D shapes are all set up. Now we can add our images to them.

    Creating a Texture from Our Images Using Canvas

    Here’s the cool part: we’ll put all our images onto a canvas, then use that canvas as a texture on our Billboard shape.

    To make this easier, I created some helper functions that simplify the whole process.

    getCanvasTexture.js

    import * as THREE from 'three';
    
    /**
    * Preloads an image and calculates its dimensions
    */
    async function preloadImage(imageUrl, axis, canvasHeight, canvasWidth) {
        const img = new Image();
    
        img.crossOrigin = 'anonymous';
    
        await new Promise((resolve, reject) => {
            img.onload = () => resolve();
            img.onerror = () => reject(new Error(`Failed to load image: ${imageUrl}`));
            img.src = imageUrl;
        });
    
        const aspectRatio = img.naturalWidth / img.naturalHeight;
    
        let calculatedWidth;
        let calculatedHeight;
    
        if (axis === 'x') {
            // Horizontal layout: scale to fit canvasHeight
            calculatedHeight = canvasHeight;
            calculatedWidth = canvasHeight * aspectRatio;
            } else {
            // Vertical layout: scale to fit canvasWidth
            calculatedWidth = canvasWidth;
            calculatedHeight = canvasWidth / aspectRatio;
        }
    
        return { img, width: calculatedWidth, height: calculatedHeight };
    }
    
    function calculateCanvasDimensions(imageData, axis, gap, canvasHeight, canvasWidth) {
        if (axis === 'x') {
            const totalWidth = imageData.reduce(
            (sum, data, index) => sum + data.width + (index > 0 ? gap : 0), 0);
    
            return { totalWidth, totalHeight: canvasHeight };
        } else {
            const totalHeight = imageData.reduce(
            (sum, data, index) => sum + data.height + (index > 0 ? gap : 0), 0);
    
            return { totalWidth: canvasWidth, totalHeight };
        }
    }
    
    function setupCanvas(canvasElement, context, dimensions) {
        const { totalWidth, totalHeight } = dimensions;
        const devicePixelRatio = Math.min(window.devicePixelRatio || 1, 2);
    
        canvasElement.width = totalWidth * devicePixelRatio;
        canvasElement.height = totalHeight * devicePixelRatio;
    
        if (devicePixelRatio !== 1) context.scale(devicePixelRatio, devicePixelRatio);
    
        context.fillStyle = '#ffffff';
        context.fillRect(0, 0, totalWidth, totalHeight);
    }
    
    function drawImages(context, imageData, axis, gap) {
        let currentX = 0;
        let currentY = 0;
    
        context.save();
    
        for (const data of imageData) {
            context.drawImage(data.img, currentX, currentY, data.width, data.height);
    
            if (axis === 'x') currentX += data.width + gap;
            else currentY += data.height + gap;
        }
    
        context.restore();
    }
    
    function createTextureResult(canvasElement, dimensions) {
        const texture = new THREE.CanvasTexture(canvasElement);
        texture.needsUpdate = true;
        texture.wrapS = THREE.RepeatWrapping;
        texture.wrapT = THREE.ClampToEdgeWrapping;
        texture.generateMipmaps = false;
        texture.minFilter = THREE.LinearFilter;
        texture.magFilter = THREE.LinearFilter;
    
        return {
            texture,
            dimensions: {
                width: dimensions.totalWidth,
                height: dimensions.totalHeight,
                aspectRatio: dimensions.totalWidth / dimensions.totalHeight,
            },
        };
    }
    
    export async function getCanvasTexture({
        images,
        gap = 10,
        canvasHeight = 512,
        canvasWidth = 512,
        canvas,
        ctx,
        axis = 'x',
    }) {
        if (!images.length) throw new Error('No images');
    
        // Create canvas and context if not provided
        const canvasElement = canvas || document.createElement('canvas');
        const context = ctx || canvasElement.getContext('2d');
    
        if (!context) throw new Error('No context');
    
        // Preload all images in parallel
        const imageData = await Promise.all(
            images.map((image) => preloadImage(image.url, axis, canvasHeight, canvasWidth))
        );
    
        // Calculate total canvas dimensions
        const dimensions = calculateCanvasDimensions(imageData, axis, gap, canvasHeight, canvasWidth);
    
        // Setup canvas
        setupCanvas(canvasElement, context, dimensions);
    
        // Draw all images
        drawImages(context, imageData, axis, gap);
    
        // Create and return texture result
        return createTextureResult(canvasElement, dimensions)
    }

    Then we can also create a useCollageTexture hook that we can easily use in our components.

    useCollageTexture.jsx

    import { useState, useEffect, useCallback } from 'react';
    import { getCanvasTexture } from '@/webgl/helpers/getCanvasTexture';
    
    export function useCollageTexture(images, options = {}) {
    const [textureResults, setTextureResults] = useState(null);
    const [isLoading, setIsLoading] = useState(true);
    const [error, setError] = useState(null);
    
    const { gap = 0, canvasHeight = 512, canvasWidth = 512, axis = 'x' } = options;
    
    const createTexture = useCallback(async () => {
        try {
            setIsLoading(true);
            setError(null);
    
            const result = await getCanvasTexture({
                images,
                gap,
                canvasHeight,
                canvasWidth,
                axis,
            });
    
            setTextureResults(result);
    
        } catch (err) {
            setError(err instanceof Error ? err : new Error('Failed to create texture'));
        } finally {
            setIsLoading(false);
        }
    }, [images, gap, canvasHeight, canvasWidth, axis]);
    
        useEffect(() => {
            if (images.length > 0) createTexture();
        }, [images.length, createTexture]);
    
        return {
            texture: textureResults?.texture || null,
            dimensions: textureResults?.dimensions || null,
            isLoading,
            error,
        };
    }

    Adding the Canvas to Our Billboard

    Now let’s use our useCollageTexture hook on our page. We’ll create some simple loading logic. It takes a second to fetch all the images and put them onto the canvas. Then we’ll pass our texture and dimensions of canvas into the Billboard component.

    page.jsx

    'use client';
    
    import styles from './page.module.scss';
    import Billboard from '@/components/webgl/Billboard/Billboard';
    import Banner from '@/components/webgl/Banner/Banner';
    import Loader from '@/components/ui/modules/Loader/Loader';
    import images from '@/data/images';
    import { View } from '@/webgl/View';
    import { PerspectiveCamera } from '@react-three/drei';
    import { useCollageTexture } from '@/hooks/useCollageTexture';
    
    const COUNT = 10;
    const GAP = 3.2;
    
    export default function Home() {
        const { texture, dimensions, isLoading } = useCollageTexture(images); // <-- getting the texture and dimensions from the useCollageTexture hook
    
        if (isLoading) return <Loader />; // <-- showing the loader when the texture is loading
    
        return (
            <div className={styles.page}>
                <View className={styles.view} orbit={false}>
                    <PerspectiveCamera makeDefault fov={7} position={[0, 0, 100]} near={0.01} far={100000} />
                    <group rotation={[-0.15, 0, -0.2]}>
                        {Array.from({ length: COUNT }).map((_, index) => [
                            <Billboard
                                key={`billboard-${index}`}
                                radius={5}
                                rotation={[0, index * Math.PI * 0.5, 0]}
                                position={[0, (index - (Math.ceil(COUNT / 2) - 1)) * GAP, 0]}
                                texture={texture} // <--passing the texture to the billboard
                                dimensions={dimensions} // <--passing the dimensions to the billboard
                            />,
                            <Banner
                                key={`banner-${index}`}
                                radius={5.035}
                                rotation={[0, 0, 0.085]}
                                position={[
                                    0,
                                    (index - (Math.ceil(COUNT / 2) - 1)) * GAP - GAP * 0.5,
                                    0,
                                ]}
                            />,
                        ])}
                    </group>
                </View>
            </div>
        );
    }

    Inside the Billboard component, we need to properly map this texture to make sure everything fits correctly. The width of our canvas will match the circumference of the cylinder, and we’ll center the y position of the texture. This way, all the images keep their resolution and don’t get squished or stretched.

    Billboard.jsx

    'use client';
    
    import * as THREE from 'three';
    import { useRef } from 'react';  
    
    function setupCylinderTextureMapping(texture, dimensions, radius, height) {
        const cylinderCircumference = 2 * Math.PI * radius;
        const cylinderHeight = height;
        const cylinderAspectRatio = cylinderCircumference / cylinderHeight;
    
        if (dimensions.aspectRatio > cylinderAspectRatio) {
            // Canvas is wider than cylinder proportionally
            texture.repeat.x = cylinderAspectRatio / dimensions.aspectRatio;
            texture.repeat.y = 1;
            texture.offset.x = (1 - texture.repeat.x) / 2;
        } else {
            // Canvas is taller than cylinder proportionally
            texture.repeat.x = 1;
            texture.repeat.y = dimensions.aspectRatio / cylinderAspectRatio;
        }
    
        // Center the texture
        texture.offset.y = (1 - texture.repeat.y) / 2;
    }
    
    function Billboard({ texture, dimensions, radius = 5, ...props }) {
        const ref = useRef(null);
    
        setupCylinderTextureMapping(texture, dimensions, radius, 2);
    
        return (
            <mesh ref={ref} {...props}>
                <cylinderGeometry args={[radius, radius, 2, 100, 1, true]} />
                <meshBasicMaterial map={texture} side={THREE.DoubleSide} />
            </mesh>
        );
    }
    
    export default Billboard;

    Now let’s animate them using the useFrame hook. The trick to animating these images is to just move the X offset of the texture. This gives us the effect of a rotating mesh, when really we’re just moving the texture offset.

    Billboard.jsx

    'use client';
    
    import * as THREE from 'three';
    import { useRef } from 'react';
    import { useFrame } from '@react-three/fiber';  
    
    function setupCylinderTextureMapping(texture, dimensions, radius, height) {
        const cylinderCircumference = 2 * Math.PI * radius;
        const cylinderHeight = height;
        const cylinderAspectRatio = cylinderCircumference / cylinderHeight;
    
        if (dimensions.aspectRatio > cylinderAspectRatio) {
            // Canvas is wider than cylinder proportionally
            texture.repeat.x = cylinderAspectRatio / dimensions.aspectRatio;
            texture.repeat.y = 1;
            texture.offset.x = (1 - texture.repeat.x) / 2;
        } else {
            // Canvas is taller than cylinder proportionally
            texture.repeat.x = 1;
            texture.repeat.y = dimensions.aspectRatio / cylinderAspectRatio;
        }
    
        // Center the texture
        texture.offset.y = (1 - texture.repeat.y) / 2;
    }
    
    function Billboard({ texture, dimensions, radius = 5, ...props }) {
        const ref = useRef(null);
    
        setupCylinderTextureMapping(texture, dimensions, radius, 2);
    
        useFrame((state, delta) => {
            if (texture) texture.offset.x += delta * 0.001;
        });
    
        return (
            <mesh ref={ref} {...props}>
                <cylinderGeometry args={[radius, radius, 2, 100, 1, true]} />
                <meshBasicMaterial map={texture} side={THREE.DoubleSide} />
            </mesh>
        );
    }
    
    export default Billboard;

    I think it would look even better if we made the back of the images a little darker. To do this, I created MeshImageMaterial – it’s just an extension of MeshBasicMaterial that makes our backface a bit darker.

    MeshImageMaterial.js

    import * as THREE from 'three';
    import { extend } from '@react-three/fiber';
    
    export class MeshImageMaterial extends THREE.MeshBasicMaterial {
        constructor(parameters = {}) {
            super(parameters);
            this.setValues(parameters);
        }
    
        onBeforeCompile = (shader) => {
            shader.fragmentShader = shader.fragmentShader.replace(
                '#include <color_fragment>',
                /* glsl */ `#include <color_fragment>
                if (!gl_FrontFacing) {
                vec3 blackCol = vec3(0.0);
                diffuseColor.rgb = mix(diffuseColor.rgb, blackCol, 0.7);
                }
                `
            );
        };
    }
    
    extend({ MeshImageMaterial });

    Billboard.jsx

    'use client';
    
    import * as THREE from 'three';
    import { useRef } from 'react';
    import { useFrame } from '@react-three/fiber';
    import '@/webgl/materials/MeshImageMaterial';
    
    function setupCylinderTextureMapping(texture, dimensions, radius, height) {
        const cylinderCircumference = 2 * Math.PI * radius;
        const cylinderHeight = height;
        const cylinderAspectRatio = cylinderCircumference / cylinderHeight;
    
        if (dimensions.aspectRatio > cylinderAspectRatio) {
            // Canvas is wider than cylinder proportionally
            texture.repeat.x = cylinderAspectRatio / dimensions.aspectRatio;
            texture.repeat.y = 1;
            texture.offset.x = (1 - texture.repeat.x) / 2;
        } else {
            // Canvas is taller than cylinder proportionally
            texture.repeat.x = 1;
            texture.repeat.y = dimensions.aspectRatio / cylinderAspectRatio;
        }
    
        // Center the texture
        texture.offset.y = (1 - texture.repeat.y) / 2;
    }
    
    function Billboard({ texture, dimensions, radius = 5, ...props }) {
        const ref = useRef(null);
    
        setupCylinderTextureMapping(texture, dimensions, radius, 2);
    
        useFrame((state, delta) => {
            if (texture) texture.offset.x += delta * 0.001;
        });
    
        return (
            <mesh ref={ref} {...props}>
                <cylinderGeometry args={[radius, radius, 2, 100, 1, true]} />
                <meshImageMaterial map={texture} side={THREE.DoubleSide} toneMapped={false} />
            </mesh>
        );
    }
    
    export default Billboard;

    And now we have our images moving around cylinders. Next, we’ll focus on banners (or marquees, whatever you prefer).

    Adding Texture to the Banner

    The last thing we need to fix is our Banner component. I wrapped it with this texture. Feel free to take it and edit it however you want, but remember to keep the proper dimensions of the texture.

    We simply import our texture using the useTexture hook, map it onto our material, and animate the texture offset just like we did in our Billboard component.

    Billboard.jsx

    'use client';
    
    import * as THREE from 'three';
    import bannerTexture from '@/assets/images/banner.jpg';
    import { useTexture } from '@react-three/drei';
    import { useFrame } from '@react-three/fiber';
    import { useRef } from 'react';
    
    function Banner({ radius = 1.6, ...props }) {
        const ref = useRef(null);
    
        const texture = useTexture(bannerTexture.src);
        texture.wrapS = texture.wrapT = THREE.RepeatWrapping;
    
        useFrame((state, delta) => {
            if (!ref.current) return;
            const material = ref.current.material;
            if (material.map) material.map.offset.x += delta / 30;
        });
    
        return (
            <mesh ref={ref} {...props}>
                <cylinderGeometry
                    args={[radius, radius, radius * 0.07, radius * 80, radius * 10, true]}
                />
                <meshBasicMaterial
                    map={texture}
                    map-anisotropy={16}
                    map-repeat={[15, 1]}
                    side={THREE.DoubleSide}
                    toneMapped={false}
                    backfaceRepeatX={3}
                />
            </mesh>
        );
    }
    
    export default Banner;

    Nice! Now we have something cool, but I think it would look even cooler if we replaced the backface with something different. Maybe a gradient? For this, I created another extension of MeshBasicMaterial called MeshBannerMaterial. As you probably guessed, we just put a gradient on the backface. That’s it! Let’s use it in our Banner component.

    We replace the MeshBasicMaterial with MeshBannerMaterial and now it looks like this!

    MeshBannerMaterial.js

    import * as THREE from 'three';
    import { extend } from '@react-three/fiber';
    
    export class MeshBannerMaterial extends THREE.MeshBasicMaterial {
        constructor(parameters = {}) {
            super(parameters);
            this.setValues(parameters);
    
            this.backfaceRepeatX = 1.0;
    
            if (parameters.backfaceRepeatX !== undefined)
    
            this.backfaceRepeatX = parameters.backfaceRepeatX;
        }
    
        onBeforeCompile = (shader) => {
            shader.uniforms.repeatX = { value: this.backfaceRepeatX * 0.1 };
            shader.fragmentShader = shader.fragmentShader
            .replace(
                '#include <common>',
                /* glsl */ `#include <common>
                uniform float repeatX;
    
                vec3 pal( in float t, in vec3 a, in vec3 b, in vec3 c, in vec3 d ) {
                    return a + b*cos( 6.28318*(c*t+d) );
                }
                `
            )
            .replace(
                '#include <color_fragment>',
                /* glsl */ `#include <color_fragment>
                if (!gl_FrontFacing) {
                diffuseColor.rgb = pal(vMapUv.x * repeatX, vec3(0.5,0.5,0.5),vec3(0.5,0.5,0.5),vec3(1.0,1.0,1.0),vec3(0.0,0.10,0.20) );
                }
                `
            );
        };
    }
    
    extend({ MeshBannerMaterial });

    Banner.jsx

    'use client';
    
    import * as THREE from 'three';
    import bannerTexture from '@/assets/images/banner.jpg';
    import { useTexture } from '@react-three/drei';
    import { useFrame } from '@react-three/fiber';
    import { useRef } from 'react';
    import '@/webgl/materials/MeshBannerMaterial';
    
    function Banner({ radius = 1.6, ...props }) {
    const ref = useRef(null);
    
    const texture = useTexture(bannerTexture.src);
    
    texture.wrapS = texture.wrapT = THREE.RepeatWrapping;
    
    useFrame((state, delta) => {
        if (!ref.current) return;
    
        const material = ref.current.material;
    
        if (material.map) material.map.offset.x += delta / 30;
    });
    
    return (
        <mesh ref={ref} {...props}>
            <cylinderGeometry
                args={[radius, radius, radius * 0.07, radius * 80, radius * 10, true]}
            />
            <meshBannerMaterial
                map={texture}
                map-anisotropy={16}
                map-repeat={[15, 1]}
                side={THREE.DoubleSide}
                toneMapped={false}
                backfaceRepeatX={3}
            />
        </mesh>
    );
    }
    
    export default Banner;

    And now we have it ✨

    Check out the demo

    You can experiment with this method in lots of ways. For example, I created 2 more examples with shapes I made in Blender, and mapped canvas textures on them. You can check them out here:

    Final Words

    Check out the final versions of all demos:

    I hope you enjoyed this tutorial and learned something new!

    Feel free to check out the source code for more details!



    Source link

  • Elastic Grid Scroll: Creating Lag-Based Layout Animations with GSAP ScrollSmoother

    Elastic Grid Scroll: Creating Lag-Based Layout Animations with GSAP ScrollSmoother


    You’ve probably seen this kind of scroll effect before, even if it doesn’t have a name yet. (Honestly, we need a dictionary for all these weird and wonderful web interactions. If you’ve got a talent for naming things…do it. Seriously. The internet is waiting.)

    Imagine a grid of images. As you scroll, the columns don’t move uniformly but instead, the center columns react faster, while those on the edges trail behind slightly. It feels soft, elastic, and physical, almost like scrolling with weight, or elasticity.

    You can see this amazing effect on sites like yzavoku.com (and I’m sure there’s a lot more!).

    So what better excuse to use the now-free GSAP ScrollSmoother? We can recreate it easily, with great performance and full control. Let’s have a look!

    What We’re Building

    We’ll take CSS grid based layout and add some magic:

    • Inertia-based scrolling using ScrollSmoother
    • Per-column lag, calculated dynamically based on distance from the center
    • A layout that adapts to column changes

    HTML Structure

    Let’s set up the markup with figures in a grid:

    <div class="grid">
      <figure class="grid__item">
        <div class="grid__item-img" style="background-image: url(assets/1.webp)"></div>
        <figcaption class="grid__item-caption">Zorith - L91</figcaption>
      </figure>
      <!-- Repeat for more items -->
    </div>

    Inside the grid, we have many .grid__item figures, each with a background image and a label. These will be dynamically grouped into columns by JavaScript, based on how many columns CSS defines.

    CSS Grid Setup

    .grid {
      display: grid;
      grid-template-columns: repeat(var(--column-count), minmax(var(--column-size), 1fr));
      grid-column-gap: var(--c-gap);
      grid-row-gap: var(--r-gap);
    }
    
    .grid__column {
      display: flex;
      flex-direction: column;
      gap: var(--c-gap);
    }

    We define all the variables in our root.

    In our JavaScript then, we’ll change the DOM structure by inserting .grid__column wrappers around groups of items, one per colum, so we can control their motion individually. Why are we doing this? It’s a bit lighter to move columns rather then each individual item.

    JavaScript + GSAP ScrollSmoother

    Let’s walk through the logic step-by-step.

    1. Enable Smooth Scrolling and Lag Effects

    gsap.registerPlugin(ScrollTrigger, ScrollSmoother);
    
    const smoother = ScrollSmoother.create({
      smooth: 1, // Inertia intensity
      effects: true, // Enable per-element scroll lag
      normalizeScroll: true, // Fixes mobile inconsistencies
    });

    This activates GSAP’s smooth scroll layer. The effects: true flag lets us animate elements with lag, no scroll listeners needed.

    2. Group Items Into Columns Based on CSS

    const groupItemsByColumn = () => {
      const gridStyles = window.getComputedStyle(grid);
      const columnsRaw = gridStyles.getPropertyValue('grid-template-columns');
    
      const numColumns = columnsRaw.split(' ').filter(Boolean).length;
    
      const columns = Array.from({ length: numColumns }, () => []); // Initialize column arrays
    
      // Distribute grid items into column buckets
      grid.querySelectorAll('.grid__item').forEach((item, index) => {
        columns[index % numColumns].push(item);
      });
    
      return { columns, numColumns };
    };

    This method groups your grid items into arrays, one for each visual column, using the actual number of columns calculated from the CSS.

    3. Create Column Wrappers and Assign Lag

    const buildGrid = (columns, numColumns) => {
    
      const fragment = document.createDocumentFragment(); // Efficient DOM batch insertion
      const mid = (numColumns - 1) / 2; // Center index (can be fractional)
      const columnContainers = [];
    
      // Loop over each column
      columns.forEach((column, i) => {
        const distance = Math.abs(i - mid); // Distance from center column
        const lag = baseLag + distance * lagScale; // Lag based on distance from center
    
        const columnContainer = document.createElement('div'); // New column wrapper
        columnContainer.className = 'grid__column';
    
        // Append items to column container
        column.forEach((item) => columnContainer.appendChild(item));
    
        fragment.appendChild(columnContainer); // Add to fragment
        columnContainers.push({ element: columnContainer, lag }); // Save for lag effect setup
      });
    
      grid.appendChild(fragment); // Add all columns to DOM at once
      return columnContainers;
    };

    The lag value increases the further a column is from the center, creating that elastic “catch up” feel during scroll.

    4. Apply Lag Effects to Each Column

    const applyLagEffects = (columnContainers) => {
      columnContainers.forEach(({ element, lag }) => {
        smoother.effects(element, { speed: 1, lag }); // Apply individual lag per column
      });
    };

    ScrollSmoother handles all the heavy lifting, we just pass the desired lag.

    5. Handle Layout on Resize

    // Rebuild the layout only if the number of columns has changed on window resize
    window.addEventListener('resize', () => {
      const newColumnCount = getColumnCount();
      if (newColumnCount !== currentColumnCount) {
        init();
      }
    });

    This ensures our layout stays correct across breakpoints and column count changes (handled via CSS).

    And that’s it!

    Extend This Further

    Now, there’s lots of ways to build upon this and add more jazz!

    For example, you could:

    • add scroll-triggered opacity or scale animations
    • use scroll velocity to control effects (see demo 2)
    • adapt this pattern for horizontal scroll layouts

    Exploring Variations

    Once you have the core concept in place, there are four demo variations you can explore. Each one shows how different lag values and scroll-based interactions can influence the experience.

    You can adjust which columns respond faster, or play with subtle scaling and transforms based on scroll velocity. Even small changes can shift the rhythm and tone of the layout in interesting ways. And don’t forget: changing the look of the grid itself, like the image ratio or gaps, will give this a whole different feel!

    Now it’s your turn. Tweak it, break it, rebuild it, and make something cool.

    I really hope you enjoy this effect! Thanks for checking by 🙂



    Source link