32 C
New York
Tuesday, July 29, 2025

Constructed to Transfer: A Nearer Have a look at the Animations Behind Eduard Bodak’s Portfolio



For months, Eduard Bodak has been sharing glimpses of his visually wealthy new web site. Now, he’s pulling again the curtain to stroll us via how three of its most hanging animations have been constructed. On this behind-the-scenes look, he shares the reasoning, technical selections, and classes discovered—from efficiency trade-offs to working with CSS variables and a customized JavaScript structure.

Overview

On this breakdown, I’ll stroll you thru three of the core GSAP animations on my web site: flipping 3D playing cards that animate on scroll, an interactive card that reacts to mouse motion on the pricing web page, and a round format of playing cards that subtly rotates as you scroll. I’ll share how I constructed each, why I made sure selections, and what I discovered alongside the best way.

I’m utilizing Locomotive Scroll V5 on this mission to deal with scroll progress and viewport detection. Because it already gives built-in progress monitoring through knowledge attributes and CSS variables, I selected to make use of that immediately for triggering animations. ScrollTrigger gives a whole lot of comparable performance in a extra built-in method, however for this construct, I wished to maintain the whole lot centered round Locomotive’s scroll system to keep away from overlap between two scroll-handling libraries.

Personally, I really like the simplicity of Locomotive Scroll. You may simply add knowledge attributes to specify the set off offset of the aspect inside the viewport. It’s also possible to get a CSS variable --progress on the aspect via knowledge attributes. This variable represents the present progress of the aspect and ranges between 0 and 1. This alone can animate so much with simply CSS.

I used this mission to shift my focus towards extra animations and visible particulars. It taught me so much about GSAP, CSS, and the right way to alter animations based mostly on what feels proper. I’ve all the time wished to construct websites that spark a bit of emotion when folks go to them.

Observe that this setup was tailor-made to the particular wants of the mission, however in circumstances the place scroll habits, animations, and state administration must be tightly built-in, GSAP’s ScrollTrigger and ScrollSmoother can supply a extra unified basis.

Now, let’s take a more in-depth have a look at the three animations in motion!

Flipping 3D playing cards on scroll

I break up the animation into two components. The primary is in regards to the playing cards escaping on scroll. The second is about them coming again and flipping again.

Half 01

We obtained the three playing cards contained in the hero part.

<part 
 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>
</part>

Whereas I’m utilizing Locomotive Scroll, I want data-scroll to allow viewport detection on a component. data-scroll-offset specifies the set off offset of the aspect inside the viewport. It takes two values: one for the offset when the aspect enters the viewport, and a second for the offset when the aspect leaves the viewport. The identical may be constructed with GSAP’s ScrollTrigger, simply contained in the JS.

data-scroll-event-progress="progressHero" will set off the customized occasion I outlined right here. This occasion lets you retrieve the present progress of the aspect, which ranges between 0 and 1.

Contained in the JS we will add an EventListener based mostly on the customized occasion we outlined. Getting the progress from it and switch it to the GSAP timeline.

this.handleProgress = (e) => {
 const { progress } = e.element;
 this.timeline?.progress(progress);
};

window.addEventListener("progressHero", this.handleProgress);

I’m utilizing JS courses in my mission, due to this fact I’m utilizing this in my context.

Subsequent, we retrieve all of the playing cards.

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

this.aspect is right here our part we outlined earlier than, so it’s data-hero-animation.

Constructing now the timeline technique inside the category. Getting the present timeline progress. Killing the previous timeline and clearing any GSAP-applied inline types (like transforms, opacity, and many others.) to keep away from residue.

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

Utilizing requestAnimationFrame() to keep away from format thrashing. Initializes a brand new, paused GSAP timeline. Whereas we’re utilizing Locomotive Scroll it’s essential that we pause the timeline, so the progress of Locomotive can deal with 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);
 });
}

Determining relative positioning per card. targetY strikes every card down so it ends close to the underside of the container. yOffsets and rotationZValues give every card a novel 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 place = index - 1;
   const elementRect = this.aspect.getBoundingClientRect();
   const cardRect = this.heroCards[0]?.getBoundingClientRect();
   const targetY = elementRect.top - cardRect.top;
   const yOffsets = [16, 32, 48];
   const rotationZValues = [-12, 0, 12];
  
   // timeline goes right here
  });

  this.timeline.progress(progress);
  this.timeline.paused(true);
 });
}

The precise GSAP timeline. Playing cards slide left or proper based mostly on their index (x). Rotate on Z barely to look scattered. Slide downward (y) to focus on place. Shrink and tilt (scale, rotateX) for a 3D really feel. index * 0.012: provides a refined stagger between playing 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 place = index - 1;
   const elementRect = this.aspect.getBoundingClientRect();
   const cardRect = this.heroCards[0]?.getBoundingClientRect();
   const targetY = elementRect.top - cardRect.top;
   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 are able to now arrange GSAP’s matchMedia() to make use of it. We are able to additionally create completely different timelines based mostly on the viewport. For instance, to regulate the animation on cellular, the place such an immersive impact wouldn’t work as properly. Even for customers preferring lowered movement, the animation may merely transfer the playing cards barely down and fade them out, as you may see on the reside web site.

setupBreakpoints() {
 this.mm.add(
  {
   desktop: "(min-width: 768px)",
   cellular: "(max-width: 767px)",
   reducedMotion: "(prefers-reduced-motion: scale back)",
  },
  (context) => {
   this.timeline?.kill?.();

   if (context.situations.desktop) this.computeDesktopTimeline();

   return () => {
    this.timeline?.kill?.();
   };
  }
 );
}

Add this to our init() technique to initialize the category after we name it.

init() {
 this.setupBreakpoints();
}

We are able to additionally add a div with a background coloration on high of the cardboard and animate its opacity on scroll so it easily disappears.

Whenever you look carefully, the playing cards are floating a bit. To attain that, we will add a repeating animation to the playing cards. It’s essential to animate yPercent right here, as a result of we already animated y earlier, so there received’t be any conflicts.

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

gsap.utils.random(1.5, 2.5) is useful to make every floating animation a bit completely different, so it seems to be extra pure. repeatRefresh: true lets the length refresh on each repeat.

Half 02

We principally have the identical construction as earlier than. Solely now we’re utilizing a sticky container. The service_container has top: 350vh, and the service_sticky has min-height: 100vh. That’s our house to play the animation.

<part 
 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>
</part>

Within the JS, we will use the progressService occasion as earlier than to get our Locomotive Scroll progress. We simply have one other timeline right here. I’m utilizing keyframes to actually fine-tune the animation.

this.serviceCards.forEach((card, index) => {
  const place = 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 place = 2 - index - 1 modifications the place, so playing cards begin unfold out: proper, middle, left. With that we will use these arrays [12, 0, -12] in the appropriate order.

There’s the identical setupBreakpoints() technique as earlier than, so we truly simply want to alter the timeline animation and might use the identical setup as earlier than, solely in a brand new JS class.

We are able to add the identical floating animation we utilized in half 01, after which we’ve the disappearing/showing card impact.

Half 2.1

One other micro element in that animation is the small progress preview of the three playing cards within the high proper.

We add data-scroll-css-progress to the earlier part to get a CSS variable --progress starting from 0 to 1, which can be utilized for dynamic CSS results. This knowledge attribute comes from Locomotive Scroll.

<part 
 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>
 ...
</part>

Utilizing CSS calc() with min() and max() to set off animations at particular progress factors. On this case, the primary animation begins at 0% and finishes at 33%, the second begins at 33% and finishes at 66%, and the final begins at 66% and finishes at 100%.

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

 &:nth-child(2) {
  mask-image: linear-gradient(
   to high,
   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%)
  );
  rework: translate3d(0, calc(rem(4px) * (1 - min(max((var(--progress) - 0.33) * 3, 0), 1))), 0);
 }

 &:nth-child(3) {
  mask-image: linear-gradient(
   to high,
   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%)
  );
  rework: translate3d(0, calc(rem(4px) * (1 - min(max((var(--progress) - 0.66) * 3, 0), 1))), 0);
 }
}

Card rotating on mouse motion

The cardboard is constructed just like the earlier ones. It has a entrance and a again.

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

On a more in-depth look, you may see a small slide-in animation of the cardboard earlier than the mouse motion takes impact. That is inbuilt GSAP utilizing the onComplete() callback within the timeline. this.card refers back to the aspect 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",
  length: 1,
  ease: "elastic.out(1,0.75)",
  onComplete: () => {
   this.initAnimation();
  },
 }
);

I’m utilizing an elastic easing that I obtained from GSAPs Ease Visualizer. The timeline performs when the web page hundreds and triggers the mouse motion animation as soon as full.

In our initAnimation() technique, we will use GSAP’s matchMedia() to allow the mouse motion solely when hover and mouse enter can be found.

this.mm = gsap.matchMedia();

initAnimation() {
 this.mm.add("(hover: hover) and (pointer: superb) and (prefers-reduced-motion: no-preference)", () => {
  gsap.ticker.add(this.mouseMovement);

  return () => {
   gsap.ticker.take away(this.mouseMovement);
  };
 });

 this.mm.add("(hover: none) and (pointer: coarse) and (prefers-reduced-motion: no-preference)", () => {
  ...
 });
}

By utilizing the media queries hover: hover and pointer: superb, we goal solely units that assist a mouse and hover. With prefers-reduced-motion: no-preference, we add this animation solely when lowered movement shouldn’t be enabled, making it extra accessible. For contact units or smartphones, we will use hover: none and pointer: coarse to use a distinct animation.

I’m utilizing gsap.ticker to run the strategy this.mouseMovement, which comprises the logic for dealing with the rotation animation.

I initially began with one of many free sources from Osmo (mouse follower) and constructed this mouse motion animation on high of it. I simplified it to solely use the mouse’s x place, which was all I wanted.

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,
    length: 0.5,
    ease: "power2.out",
  });
}

I additionally added calculations for a way a lot the cardboard can rotate on the y-axis, and it rotates the z-axis accordingly. That’s how we get this mouse motion animation.

When constructing these animations, there are all the time some edge circumstances I didn’t contemplate earlier than. For instance, what occurs once I transfer my mouse exterior the window? Or if I hover over a hyperlink or button, ought to the rotation animation nonetheless play?

I added habits in order that when the mouse strikes exterior, the cardboard rotates again to its authentic place. The identical habits applies when the mouse leaves the hero part or hovers over navigation parts.

I added a state flag this.isHovering. Firstly of mouseMovement(), we verify if this.isHovering is fake, and in that case, return early. The onMouseLeave technique rotates the cardboard again to its authentic place.

mouseMovement()  !this.isHovering) return;

  ...


onMouseEnter() {
  this.isHovering = true;
}

onMouseLeave() {
  this.isHovering = false;

  gsap.to(this.card, {
    rotationX: 0,
    rotationY: 0,
    rotationZ: 6,
    length: 1.5,
    ease: "elastic.out(1,0.75)",
  });
}

Utilizing our initAnimation() technique from earlier than, with these changes added.

initAnimation() {
 this.mm.add("(hover: hover) and (pointer: superb) 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.take away(this.mouseMovement);
  };
 });

 this.mm.add("(hover: none) and (pointer: coarse) and (prefers-reduced-motion: no-preference)", () => {
  ...
 });
}

And right here we’ve the mouse enter/depart habits.

We are able to alter it additional by including one other animation for cellular, since there’s no mouse motion there. Or a refined reflection impact on the cardboard like within the video. That is achieved by duplicating the cardboard, including an overlay with a gradient and backdrop-filter, and animating it equally to the unique card, however with reverse values.

Playing cards in a round place that barely rotate on scroll

First, we construct the bottom of the circularly positioned playing cards in CSS.

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

At first, we add all 24 playing cards, then take away those we don’t need to present later as a result of we don’t see them. Within the CSS, the .wheel makes use of a grid show, so we apply grid-area: 1 / 1 to stack the playing cards. We later add an overlay earlier than the wheel with the identical grid-area. By utilizing em we will use a fluid font-size to regulate the dimensions fairly clean on resizing the viewport.

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

We use the identical grid stacking method for the gadgets. On the merchandise wrapper, we apply the CSS variables outlined within the HTML to rotate the playing cards.

.wheel_items {
 width: 100%;
 top: 100%;
 show: grid;
}

.wheel_item-wrap {
 rework: rotate(calc(var(--wheel-angle) * var(--wheel-index)));
 grid-area: 1 / 1;
 justify-self: middle;
 top: 100%;
}

Contained in the merchandise, there’s solely a picture of the cardboard background. The merchandise makes use of translateY(-100%) to place the cardboard on the high fringe of the merchandise.

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

We are able to take away the cardboard from 8 to 19 as we don’t see them behind the overlay. It ought to appear like this now.

By including the info attributes and setup for viewport detection from Locomotive Scroll, which we utilized in earlier modules, we will merely add our GSAP timeline for the rotation animation.

this.timeline = gsap.timeline({ paused: true });

this.timeline.to(this.wheel, {
 rotate: -65,
 length: 1,
 ease: "linear",
});

We are able to add a gradient overlay on high of the playing cards.

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

And that’s our closing impact.

Conclusion

There are in all probability smarter methods to construct these animations than I used. However since that is my first web site after altering my course and GSAP, Locomotive Scroll V5, Swup.js, and CSS animations, I’m fairly pleased with the end result. This mission turned a private playground for studying, it actually exhibits that you just be taught finest by constructing what you think about. I don’t know what number of instances I refactored my code alongside the best way, nevertheless it gave me understanding of making accessible animations.

I additionally did a whole lot of different animations on the positioning, principally utilizing CSS animations mixed with JavaScript for the logic behind them.

There are additionally so many nice sources on the market to be taught GSAP and CSS.

The place I discovered essentially the most:

It’s all about how you utilize it. You may copy and paste, which is quick however doesn’t assist you be taught a lot. Or you may construct on it your personal method and make it yours, that’s at the least what helped me be taught essentially the most ultimately.



Supply hyperlink

Related Articles

LEAVE A REPLY

Please enter your comment!
Please enter your name here

Latest Articles