Creating 3D Scroll-Driven Text Animations with CSS and GSAP

0
9


In this tutorial, you’ll build three scroll-driven text effects using only CSS, JavaScript, and GSAP. Instead of
relying on a 3D library, you’ll combine CSS transforms with GSAP’s ScrollTrigger plugin to link motion directly to
scroll position, creating smooth, high-performance 3D animations.


Free GSAP 3 Express Course


Learn modern web animation using GSAP 3 with 34 hands-on video lessons and practical projects — perfect for all skill levels.


Check it out

Initial Setup

The first step is to initialize the project and set up its structure. Nothing fancy—just a simple, organized setup to
keep things clean and easy to follow.

We’ll use a class-based model, starting with an
App
class as our main entry point and three separate classes for each animation. The final project will look like this:

At the heart of this setup is GSAP. We’ll register its ScrollTrigger and ScrollSmoother plugins, which handle smooth
scrolling and scroll-based animations throughout all three effects. ScrollSmoother ensures consistent, GPU-accelerated
scrolling, while ScrollTrigger ties our animations directly to scroll progress — the two plugins work together to keep
the motion perfectly synced and stutter-free.

The main entry point will be the
main.ts
file, which currently looks like this:

import { gsap } from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import { ScrollSmoother } from "gsap/ScrollSmoother";

class App {
  smoother!: ScrollSmoother;
  constructor() {
    gsap.registerPlugin(ScrollTrigger, ScrollSmoother);

    this.init();
    this.addEventListeners();
  }

  init(): void {
    this.setupScrollSmoother();
  }

  // optional
  setupScrollSmoother(): void {
    this.smoother = ScrollSmoother.create({
      smooth: 1,
      effects: true,
    });
  }

  addEventListeners(): void {
    window.addEventListener("resize", () => {
      console.log("resize");
    });
  }
}

new App();

Our examples use the TypeScript syntax (for type safety and better editor support), but you can write it identically in plain JavaScript. Simply remove the type annotations (like : void or !:) and it will work the same way.

Creating Our First Effect: Cylinder

For our first effect, we’ll position text around an invisible cylinder that reveals itself as you scroll—without
relying on a 3D library like Three.js. You can see similar examples on
BPCO
and
Sturdy
, so shout-out to those creators for the inspiration.

Building the Structure with HTML & CSS

<section class="cylinder__wrapper">
  <p class="cylinder__title">keep scrolling to see the animation</p>
  <ul class="cylinder__text__wrapper">
    <li class="cylinder__text__item">design</li>
    <li class="cylinder__text__item">development</li>
    <li class="cylinder__text__item">branding</li>
    <li class="cylinder__text__item">marketing</li>
    <li class="cylinder__text__item">copywriting</li>
    <li class="cylinder__text__item">content</li>
    <li class="cylinder__text__item">illustration</li>
    <li class="cylinder__text__item">video</li>
    <li class="cylinder__text__item">photography</li>
    <li class="cylinder__text__item">3d graphic</li>
    <li class="cylinder__text__item">scroll</li>
    <li class="cylinder__text__item">animation</li>
  </ul>
</section>
.cylinder__wrapper {
  width: 100%;
  height: 100svh;
  position: relative;
  perspective: 70vw;
  overflow: hidden;

  @media screen and (max-width: 768px) {
    perspective: 400px;
  }

  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: center;
  gap: 10rem;
}

.cylinder__text__wrapper {
  position: absolute;
  font-size: 5vw;
  line-height: 5vw;
  width: 100%;
  height: 100%;
  transform-style: preserve-3d;
  transform-origin: center center;
  font-weight: 600;
  text-align: center;

  @media screen and (min-width: 2560px) {
    font-size: 132px;
    line-height: 132px;
  }

  @media screen and (max-width: 768px) {
    font-size: 1.6rem;
    line-height: 1.6rem;
  }
}

.cylinder__text__item {
  position: absolute;
  left: 50;
  top: 50%;
  width: 100%;
  backface-visibility: hidden;
}

The key property that gives our layout a 3D look is perspective: 70vw on the .cylinder__wrapper , which adds depth to the entire effect.

The
transform-style: preserve-3d
property allows us to position child elements in 3D space using CSS.

We’ll also use the
backface-visibility
property—but more on that later.

For now, we should have something that looks like this:

We’re not quite there yet—the text is currently collapsing on top of itself because every item shares the same
position, and everything is aligned to the right side of the viewport. We’ll handle all of this with JavaScript. While
we could define positions directly in CSS, we want the effect to work seamlessly across all screen sizes, so we’ll
calculate positions dynamically instead.

Bringing It to Life with JavaScript

We’ll create a new folder named
cylinder
for this effect and define our
Cylinder
class.

First, we need to initialize the DOM elements required for this animation:

  • Wrapper:
    Keeps the user focused on this section while scrolling.
  • Text items:
    Each word or phrase positioned around an invisible cylinder.
  • Text wrapper:
    Rotates to create the 3D cylindrical effect.
  • Title:
    Triggers the animation when it enters the viewport.
import { gsap } from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';

export class Cylinder {

    title: HTMLElement;
    textWrapper: HTMLElement;
    textItems: NodeListOf<HTMLElement>;
    wrapper: HTMLElement;

    constructor() {
        this.title = document.querySelector('.cylinder__title') as HTMLElement;
        this.textWrapper = document.querySelector('.cylinder__text__wrapper') as HTMLElement;
        this.textItems = document.querySelectorAll('.cylinder__text__item') as NodeListOf<HTMLElement>;
        this.wrapper = document.querySelector('.cylinder__wrapper') as HTMLElement;
        this.init();
    }

    init() {
        console.log("init cylinder");
    }
}

Once we have all the necessary DOM elements, we can initialize our
Cylinder
class in the
main.ts
file. From there, we’re ready to position the text in 3D space by creating a
calculatePositions()
function, which looks like this:

calculatePositions(): void {
    const offset = 0.4;
    const radius = Math.min(window.innerWidth, window.innerHeight) * offset;
    const spacing = 180 / this.textItems.length;

    this.textItems.forEach((item, index) => {
      const angle = (index * spacing * Math.PI) / 180;
      const rotationAngle = index * -spacing;

      const x = 0;
      const y = Math.sin(angle) * radius;
      const z = Math.cos(angle) * radius;

      item.style.transform = `translate3d(-50%, -50%, 0) translate3d(${x}px, ${y}px, ${z}px) rotateX(${rotationAngle}deg)`;
    });
  }

This gives us the following result:

It’s looking better — we can start to see the final effect taking shape. But before moving on, let’s go over what’s
actually happening.

Understanding the Position Calculations

The
calculatePositions()
method is where the magic happens. Let’s break down how we arrange the text items around an invisible cylinder.

Defining the Cylinder Dimensions

First, we define an
offset
value of
0.4
(feel free to experiment with this!). This controls how “tight” or “wide” the cylinder appears. We then calculate the
cylinder’s
radius
by multiplying the smaller of the viewport’s width or height by the
offset
value. This approach ensures the effect scales smoothly across all screen sizes.

const offset = 0.4;
const radius = Math.min(window.innerWidth, window.innerHeight) * offset;

Evenly Distributing the Text Items

Next, we determine the spacing between each text item by dividing
180
degrees by the total number of items. This ensures that the text elements are evenly distributed along the visible
half of the cylinder.

const spacing = 180 / this.textItems.length;

Calculating Each Item’s 3D Position

Next, we calculate each text item’s position in 3D space using a bit of trigonometry. By determining the
x
,
y
, and
z
coordinates, we can position every item evenly along the cylinder’s surface:

  • x
    remains
    0
    — this keeps each item horizontally centered.
  • y
    (vertical position) is calculated using
    Math.sin()
    to create the curved layout.
  • z
    (depth) is determined using
    Math.cos()
    , pushing items forward or backward to form the 3D shape.
const angle = (index * spacing * Math.PI) / 180;
const rotationAngle = index * -spacing; const x = 0;
const y = Math.sin(angle) * radius;
const z = Math.cos(angle) * radius; 

Finally, we apply these calculations using CSS 3D transforms, positioning and rotating each item to form the
cylindrical layout.

angle
: Converts each item’s index and spacing into radians (the unit used by JavaScript’s Math functions).

rotationAngle
: Defines how much each item should rotate so that it faces outward from the cylinder.

Bringing the Cylinder to Life with ScrollTrigger

Now that we’ve positioned our text, we’ll create a new function called
createScrollTrigger()
to connect the animation to the user’s scroll position and bring the cylinder to life.

Setting Up ScrollTrigger

This is where GSAP’s ScrollTrigger plugin really shines — it lets us link the 3D rotation of our text cylinder
directly to scroll progress. Without GSAP, synchronizing this kind of 3D motion to scroll position would require a lot
of manual math and event handling.

We’ll use GSAP’s
ScrollTrigger.create()
method to define when and how the animation should behave:

ScrollTrigger.create({
    trigger: this.title,
    start: "center center",
    end: "+=2000svh",
    pin: this.wrapper,
    scrub: 2,
    animation: gsap.fromTo(
      this.textWrapper,
      { rotateX: -80 },
      { rotateX: 270, ease: "none" }
    ),
  });

GSAP handles the entire timing and rendering of the rotation internally. Thanks to ScrollTrigger, the animation stays
perfectly in sync with scrolling and performs smoothly across devices.

Under the hood,
ScrollTrigger
continuously maps scroll distance to the animation’s progress value (0–1). That means you don’t need to manually
calculate scroll offsets or handle momentum — GSAP does the pixel-to-progress conversion and updates transforms in
sync with the browser’s repaint cycle.

And with that, we finally have our result:

Breaking Down the Configuration

  • trigger:
    The element that activates the animation (in this case, the title element).
  • start:
    "center center"
    means the animation begins when the trigger’s center reaches the center of the viewport. You can adjust this to
    fine-tune when the animation starts.
  • end:
    "+=2000svh"
    extends the animation duration to 2000% of the viewport height, creating a long, smooth scroll experience. Modify
    this value to speed up or slow down the rotation.
  • pin:
    Keeps the wrapper element fixed in place while the animation plays, preventing it from scrolling away.
  • scrub:
    Set to
    2
    , this adds a smooth two-second lag between the scroll position and the animation, giving it a more natural, fluid
    feel. Try experimenting with different values to adjust the responsiveness.
  • animation:
    Defines the actual rotation effect:

    • Starts at
      rotateX: -80
      degrees (the cylinder is tilted slightly backward).
    • Ends at
      rotateX: 270
      degrees (the cylinder completes almost a full rotation).
    • ease: "none"
      ensures a linear progression that directly matches the scroll position.

As users scroll, the cylinder smoothly rotates, revealing each text item in sequence. The extended scroll duration (
2000svh
) gives viewers time to fully appreciate the 3D effect at their own pace.

If you ever need to tweak how the animation feels, focus on the
scrub
and
end
values — they directly control how
ScrollTrigger
interpolates scroll velocity into animation time.

Side Note: Understanding Backface Visibility

We mentioned it earlier, but
backface-visibility
plays a crucial role in our cylindrical animation.

.cylinder__text__item {
  position: absolute;
  top: 50%;
  left: 50%;
  width: 100%;
  backface-visibility: hidden;
}

This property hides text items when they rotate away from the viewer—when their “back” side is visible. Without it,
you’d see mirrored, reversed text as items rotate past 90 degrees, breaking the illusion of depth. By setting
backface-visibility: hidden;
, only the front-facing text is displayed, creating a clean and convincing 3D rotation.

Without this property, you might end up with something like this:

Handling Responsive Behavior

Because the cylinder’s dimensions are based on the viewport size, we need to recalculate their positions whenever the
window is resized. The
resize()
method takes care of this:

resize(): void {
  this.calculatePositions();
}

This method is called from the main
App
class, which listens for window resize events:

// src/main.ts

addEventListeners(): void {
  window.addEventListener("resize", () => {
    this.cylinder.resize();
  });
}

This ensures that when users rotate their device, resize their browser, or switch between portrait and landscape
modes, the cylinder maintains its correct proportions and positioning. The effect stays visually consistent and
preserves the 3D illusion across all screen sizes.

The Second Effect: Circle

The double circle effect is a personal favorite — it shows how we can achieve elegant, dynamic animations using just a
few clever combinations of CSS and JavaScript.

HTML Structure

The circle effect uses a dual-column layout with two separate lists of text items positioned on opposite sides of the
viewport.

<section class="circle__wrapper">
  <ul class="circle__text__wrapper__left">
    <li class="circle__text__left__item">design</li>
    <li class="circle__text__left__item">development</li>
    <li class="circle__text__left__item">branding</li>
    <!-- 24 items total -->
  </ul>
  <ul class="circle__text__wrapper__right">
    <li class="circle__text__right__item">design</li>
    <li class="circle__text__right__item">development</li>
    <li class="circle__text__right__item">branding</li>
    <!-- 24 items total -->
  </ul>
</section>

We use two unordered lists (
<ul>
) to create independent text columns. Each list contains 24 identical items that will be arranged along circular paths
during the scroll animation. The left and right wrappers enable mirrored circular motion on opposite sides of the
screen, adding symmetry to the overall effect.

CSS Foundation

The
.circle__wrapper
class defines our main animation container:

.circle__wrapper {
  position: relative;
  width: 100%;
  height: 100svh;
}

Unlike the cylindrical effect, we don’t need
perspective
or
transform-style: preserve-3d
here, since this effect relies on 2D circular motion rather than true 3D depth. Each wrapper simply fills the viewport
height, forming a clean, full-screen scroll section.

Positioning the Text Columns

The left column is positioned about 30% from the left edge of the screen:

.circle__text__wrapper__left {
  position: absolute;
  top: 50%;
  left: 30%;
  translate: -100% -50%;
}

The right column is placed at 70% from the left edge, mirroring the left column’s position:

.circle__text__wrapper__right {
  position: absolute;
  top: 50%;
  left: 70%;
  translate: 0 -50%;
  text-align: right;
}

Both wrappers are vertically centered using
top: 50%
and
translate: ... -50%
. The key difference lies in their horizontal alignment: the left wrapper uses
-100%
to shift it fully to the left, while the right wrapper uses
0
along with
text-align: right
to align its text to the right side.

Positioning Individual Text Items

Each text item is absolutely positioned and centered within its respective wrapper:

.circle__text__left__item,
.circle__text__right__item {
  position: absolute;
  font-size: 3rem;
  font-weight: 700;
  text-transform: uppercase;
  transform: translate(-50%, -50%);
}

The
transform: translate(-50%, -50%)
rule centers each item at its transform origin. At this point, all text elements are stacked on top of each other in
the middle of their respective wrappers. This is intentional — we’ll use JavaScript next to calculate each item’s
position along a circular path, creating the orbital motion effect.

For now, your layout should look something like this:

We’re not quite there yet — the text items are still stacked on top of each other because they all share the same
position. To fix this, we’ll handle the circular positioning with JavaScript, calculating each item’s coordinates
along a circular path. While we could hardcode these positions in CSS, using JavaScript allows the layout to
dynamically adapt to any screen size or number of items.

Bringing the Circle Effect to Life with JavaScript

We’ll start by creating a new folder named
circle
for this effect and defining a
Circle
class to handle all its functionality.

First, we’ll define a configuration interface and initialize the necessary DOM elements for both circles:

interface CircleConfig {
  wrapper: HTMLElement;
  items: NodeListOf<HTMLElement>;
  radius: number;
  direction: number;
}

export class Circle {
  leftConfig: CircleConfig;
  rightConfig: CircleConfig;
  centerX!: number;
  centerY!: number;

  constructor() {
    this.leftConfig = {
      wrapper: document.querySelector(".circle__text__wrapper__left") as HTMLElement,
      items: document.querySelectorAll(".circle__text__left__item"),
      radius: 0,
      direction: 1,
    };

    this.rightConfig = {
      wrapper: document.querySelector(".circle__text__wrapper__right") as HTMLElement,
      items: document.querySelectorAll(".circle__text__right__item"),
      radius: 0,
      direction: -1,
    };

    this.updateDimensions();
    this.init();
  }
}

We use a
CircleConfig
interface to store configuration data for each circle. It contains the following properties:

  • wrapper
    : The container element that holds each list of text items.
  • items
    : All individual text elements within that list.
  • radius
    : The circle’s radius, calculated dynamically based on the wrapper’s width.
  • direction
    : Determines the rotation direction —
    1
    for clockwise and
    -1
    for counterclockwise.

Notice that the left circle has
direction: 1
, while the right circle uses
direction: -1
. This setup creates perfectly mirrored motion between the two sides.

Calculating Dimensions

Before positioning any text items, we need to calculate the center point of the viewport and determine each circle’s
radius. These values will serve as the foundation for positioning every item along the circular paths.

updateDimensions(): void {
  this.centerX = window.innerWidth / 2;
  this.centerY = window.innerHeight / 2;
  this.leftConfig.radius = this.leftConfig.wrapper.offsetWidth / 2;
  this.rightConfig.radius = this.rightConfig.wrapper.offsetWidth / 2;
}

The center coordinates (
centerX
and
centerY
) define the point around which our circles orbit. Each circle’s radius is calculated as half the width of its
wrapper, ensuring the circular path scales proportionally with the container size.

Once we’ve determined these dimensions, we can initialize both circles and begin positioning the text items around
their respective paths:

init(): void {
  this.calculateInitialPositions();
}

calculateInitialPositions(): void {
  this.updateItemsPosition(this.leftConfig, 0);
  this.updateItemsPosition(this.rightConfig, 0);
}

We call
updateItemsPosition()
for both configurations, using
scrollY: 0
as the initial value. The
scrollY
parameter will come into play later when we add scroll-triggered animation—more on that soon.

Understanding the Position Calculations

The
updateItemsPosition()
method is where the real magic happens. Let’s break down how it arranges the text items evenly around the invisible
circular paths:

updateItemsPosition(config: CircleConfig, scrollY: number): void {
  const { items, radius, direction } = config;
  const totalItems = items.length;
  const spacing = Math.PI / totalItems;

  items.forEach((item, index) => {
    const angle = index * spacing - scrollY * direction * Math.PI * 2;
    const x = this.centerX + Math.cos(angle) * radius;
    const y = this.centerY + Math.sin(angle) * radius;

    const rotation = (angle * 180) / Math.PI;

    gsap.set(item, {
      x,
      y,
      rotation,
      transformOrigin: "center center",
    });
  });
}

Distributing items evenly

To ensure even spacing, we calculate the distance between each text item by dividing π (180 degrees in radians) by the
total number of items. This evenly distributes the text across half of the circle’s circumference:

const spacing = Math.PI / totalItems;

Calculating each item’s position

Next, we use basic trigonometry to calculate the position of each text item along the circular path:

const angle = index * spacing - scrollY * direction * Math.PI * 2;
const x = this.centerX + Math.cos(angle) * radius;
const y = this.centerY + Math.sin(angle) * radius;
const rotation = (angle * 180) / Math.PI;
  • angle
    : Calculates the current angle in radians. The
    scrollY * direction * Math.PI * 2
    part will later control rotation based on scroll position.
  • x
    (horizontal position): Uses
    Math.cos(angle) * radius
    to determine the horizontal coordinate relative to the circle’s center.
  • y
    (vertical position): Uses
    Math.sin(angle) * radius
    to calculate the vertical coordinate, positioning items along the circular path.
  • rotation
    : Converts the angle back into degrees and rotates each item so that it naturally follows the curve of the circle.

Finally, we use GSAP’s
gsap.set()
method to apply these calculated positions and rotations to each text item:

gsap.set(item, {
  x,
  y,
  rotation,
  transformOrigin: "center center",
});

Using
gsap.set()
instead of manually updating styles ensures GSAP keeps track of all transforms it applies. If you later add tweens or
timelines on the same elements, GSAP will reuse its internal state rather than overwriting CSS directly.

This produces the following visual result:

It’s looking much better! Both circles are now visible and forming nicely, but there’s one issue — the text on the
right side is upside down and hard to read. To fix this, we’ll need to adjust the rotation so that all text remains
upright as it moves along the circle.

Keeping the Text Readable

To maintain readability, we’ll add a conditional rotation offset based on each circle’s direction. This ensures the
text on both sides always faces the correct way:

const rotationOffset = direction === -1 ? 180 : 0;
const rotation = (angle * 180) / Math.PI + rotationOffset;

When
direction === -1
(the right circle), we add 180 degrees to flip the text so it appears right-side up. When
direction === 1
(the left circle), we keep it at 0 degrees, preserving the default orientation. This adjustment ensures that all text
remains readable as it moves along its circular path.

With that small tweak, our circles now look like this:

Perfect! Both circles are now fully readable and ready for scroll-triggered animation.

Animating with ScrollTrigger

Now that we’ve positioned our text along circular paths, let’s create a new function called
createScrollAnimations()
to bring them to life by linking their motion to the user’s scroll position.

Setting Up ScrollTrigger

We’ll use GSAP’s
ScrollTrigger.create()
method to define when and how our animation behaves as users scroll through the section:

createScrollAnimations(): void {
  ScrollTrigger.create({
    trigger: ".circle__wrapper",
    start: "top bottom",
    end: "bottom top",
    scrub: 1,
    onUpdate: (self) => {
      const scrollY = self.progress * 0.5;
      this.updateItemsPosition(this.leftConfig, scrollY);
      this.updateItemsPosition(this.rightConfig, scrollY);
    },
  });
}

And here’s our final result in action:

Breaking Down the Configuration

trigger:
The element that initiates the animation — in this case,
.circle__wrapper
. When this section enters the viewport, the animation begins.

start:
The value
"top bottom"
means the animation starts when the top of the trigger reaches the bottom of the viewport. In other words, the
rotation begins as soon as the circle section comes into view.

end:
The value
"bottom top"
indicates the animation completes when the bottom of the trigger reaches the top of the viewport. This creates a
smooth, extended scroll duration where the circles continue rotating throughout the section’s visibility.

scrub:
Setting
scrub: 1
adds a one-second delay between scroll movement and animation updates, giving the motion a smooth, natural feel. You
can tweak this value — higher numbers create a softer easing effect, while lower numbers make the animation respond
more immediately.

onUpdate:
This callback runs continuously as the user scrolls through the section. It’s responsible for linking the scroll
progress to the circular rotation of our text items:

onUpdate: (self) => {
  const scrollY = self.progress * 0.5;
  this.updateItemsPosition(this.leftConfig, scrollY);
  this.updateItemsPosition(this.rightConfig, scrollY);
}
  • self.progress
    returns a value between 0 and 1, representing how far the user has scrolled through the animation.
  • We multiply this value by
    0.5
    to control the rotation speed. This means the circles will complete half a rotation during a full scroll through the
    section. You can tweak this multiplier to make the circles spin faster or slower.
  • Finally, we call
    updateItemsPosition()
    for both circles, passing in the calculated
    scrollY
    value to update their positions in real time.

The
onUpdate
callback runs every animation frame while scrolling, giving you direct access to live scroll progress. This pattern is
ideal when you’re mixing custom math-based transforms with GSAP as you still get precise, throttled frame updates
without handling
requestAnimationFrame
yourself.

Remember the
scrollY * direction * Math.PI * 2
formula inside our
updateItemsPosition()
method? This is where it comes into play. As the user scrolls:

  • The left circle (
    direction: 1
    ) rotates clockwise.
  • The right circle (
    direction: -1
    ) rotates counterclockwise.
  • Both circles move in perfect synchronization with the scroll position, creating a balanced mirrored motion.

The result is a beautiful dual-circle animation where text items orbit smoothly as users scroll, adding a dynamic and
visually engaging motion to your layout.

The Third Effect: Tube

The third effect introduces a “tube” or “tunnel” animation, where text items are stacked along the depth axis,
creating a sense of 3D motion as users scroll forward through the scene.

HTML Structure

<section class="tube__wrapper">
  <ul class="tube__text__wrapper">
    <li class="tube__text__item">design</li>
    <li class="tube__text__item">development</li>
    <li class="tube__text__item">branding</li>
    <li class="tube__text__item">marketing</li>
    <li class="tube__text__item">copywriting</li>
    <li class="tube__text__item">content</li>
    <li class="tube__text__item">illustration</li>
    <li class="tube__text__item">video</li>
  </ul>
</section>

Unlike the Circle effect, which uses two opposing columns, the Tube effect uses a single list where each item is
positioned along the Z-axis. This creates the illusion of depth, as if the text is receding into or emerging from a 3D
tunnel.

CSS Foundation

.tube__wrapper {
  width: 100%;
  height: 100svh;
  position: relative;
  perspective: 70vw;
  overflow: hidden;
}

We’re back to using
perspective: 70vw
, just like in the Cylinder effect. This property creates the sense of depth needed for our 3D tunnel illusion. The
overflow: hidden
rule ensures that text elements don’t appear outside the visible bounds as they move through the tunnel.

.tube__text__wrapper {
  width: 100%;
  height: 100%;
  position: relative;
  transform-style: preserve-3d;
  transform-origin: center center;
}

The
transform-style: preserve-3d
property allows child elements to maintain their 3D positioning within the scene — a crucial step in creating the
tunnel depth effect that makes this animation feel immersive.

.tube__text__item {
  position: absolute;
  top: 50%;
  width: 100%;
}

Each text item is vertically centered and stretches across the full width of the container. At this point, all items
are stacked on top of one another in the same position. In the next step, we’ll use JavaScript to distribute them
along the Z-axis, giving the illusion of text emerging from or receding into a tunnel as the user scrolls.

Adding Motion with JavaScript

Just like with the Cylinder effect, we’ll create a
Tube
class to manage our 3D tunnel animation — handling initialization, positioning, and scroll-based transformations.

Initialization

export class Tube {
  private items: NodeListOf<HTMLElement>;
  private textWrapper: HTMLElement;
  private wrapper: HTMLElement;

  constructor() {
    this.wrapper = document.querySelector(".tube__wrapper") as HTMLElement;
    this.textWrapper = document.querySelector(".tube__text__wrapper") as HTMLElement;
    this.items = document.querySelectorAll(".tube__text__item");

    this.init();
  }

  private init(): void {
    this.calculatePositions();
  }
}

Here, we initialize the same core DOM elements used in the Cylinder effect: the wrapper (which we’ll later pin during
scrolling), the text wrapper (which we’ll rotate), and the individual text items (which we’ll position in 3D space).

Position Calculation

The
calculatePositions()
method works much like the one used in the Cylinder effect, with one key difference — instead of a vertical rotation,
this time we’re building a horizontal cylinder, giving the illusion of moving through a tunnel.

private calculatePositions(): void {
  const offset = 0.4;
  const radius = Math.min(window.innerWidth, window.innerHeight) * offset;
  const spacing = 360 / this.items.length;

  this.items.forEach((item, index) => {
    const angle = (index * spacing * Math.PI) / 180;

    const x = Math.sin(angle) * radius;
    const y = 0;
    const z = Math.cos(angle) * radius;
    const rotationY = index * spacing;

    item.style.transform = `translate3d(${x}px, ${y}px, ${z}px) rotateY(${rotationY}deg)`;
  });
}

The underlying math is almost identical to the Cylinder effect — we still calculate a radius, distribute items evenly
across 360 degrees, and use trigonometric functions to determine each item’s position. The key differences come from
how we map these calculations to the axes:

  • X-axis (horizontal):
    Uses
    Math.sin(angle) * radius
    to create horizontal spacing between items.
  • Y-axis (vertical):
    Set to
    0
    to keep all text items perfectly centered vertically.
  • Rotation:
    Uses
    rotateY()
    instead of
    rotateX()
    , rotating each item around the vertical axis to create a convincing tunnel-like perspective.

This setup forms a horizontal tube that extends into the screen’s depth — ideal for creating a smooth, scroll-driven
tunnel animation.

The structure is in place, but it still feels static — let’s bring it to life with animation!

ScrollTrigger Animation

Just like in the Cylinder effect, we’ll use
ScrollTrigger.create()
to synchronize our tube’s rotation with the user’s scroll position:

private createScrollTrigger(): void {
  ScrollTrigger.create({
    trigger: ".tube__title",
    start: "center center",
    end: "+=2000svh",
    pin: this.wrapper,
    scrub: 2,
    animation: gsap.fromTo(
      this.textWrapper,
      { rotateY: 0 },
      { rotateY: 360, ease: "none" }
    ),
  });
}

The configuration closely mirrors the Cylinder setup, with just one major difference — the axis of rotation:

  • trigger:
    Activates when the
    .tube__title
    element enters the viewport.
  • start / end:
    Defines a long
    2000svh
    scroll distance for a smooth, continuous animation.
  • pin:
    Keeps the entire tube section fixed in place while the animation plays.
  • scrub:
    Adds a two-second delay for smooth, scroll-synced motion.
  • animation:
    The key change — using
    rotateY
    instead of
    rotateX
    to spin the tunnel around its vertical axis.

While the Cylinder effect rotates around the horizontal axis (like a Ferris wheel), the Tube effect spins around the
vertical axis — more like a tunnel spinning toward the viewer. This creates a dynamic illusion of depth, making it
feel as if you’re traveling through 3D space as you scroll.

Reusing the same
ScrollTrigger
setup between different effects is a good pattern because it keeps scroll-linked motion consistent across your site.
You can swap axes, durations, or easing without rewriting your scroll logic.

The final result is a hypnotic tunnel animation where text items appear to rush toward and past the viewer, delivering
a true sense of motion through a cylindrical world.

Conclusion

Thank you for following along with this tutorial! We’ve explored three unique 3D text scroll effects — the Cylinder,
Circle, and Tube animations — each demonstrating a different approach to building immersive scroll-driven experiences
using GSAP, ScrollTrigger, and creative 3D CSS transforms.

When tuning typography and colors, you can create all kinds of mesmerizing looks for these effects! Check out the final demos:

I can’t wait to see what you come up with using these techniques! Feel free to tweak the parameters, mix the effects, or create entirely new variations of your own. If you build something awesome, I’d love to check it out — share your work with me on LinkedIn, Instagram, or X (Twitter).